diff --git a/bun.lock b/bun.lock index 40ffe9711..ed75df886 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.47", "@anthropic-ai/sdk": "^0.77.0", + "@opencode-ai/sdk": "1.2.6", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.6", @@ -237,6 +238,8 @@ "@oozcitak/util": ["@oozcitak/util@10.0.0", "https://registry.npmmirror.com/@oozcitak/util/-/util-10.0.0.tgz", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.6", "https://registry.npmmirror.com/@opencode-ai/sdk/-/sdk-1.2.6.tgz", {}, "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA=="], + "@oxc-project/types": ["@oxc-project/types@0.113.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.113.0.tgz", {}, "sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], diff --git a/package.json b/package.json index a5d93e450..20a515b5c 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.47", "@anthropic-ai/sdk": "^0.77.0", + "@opencode-ai/sdk": "1.2.6", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.6", diff --git a/server/api/ai/chat.ts b/server/api/ai/chat.ts index efa918463..433400c34 100644 --- a/server/api/ai/chat.ts +++ b/server/api/ai/chat.ts @@ -4,6 +4,7 @@ interface ChatBody { system: string messages: Array<{ role: 'user' | 'assistant'; content: string }> model?: string + provider?: string } /** @@ -25,6 +26,12 @@ export default defineEventHandler(async (event) => { Connection: 'keep-alive', }) + // Explicit provider routing + if (body.provider === 'opencode') { + return streamViaOpenCode(body, body.model) + } + + // Default: existing behavior (backward-compatible) const apiKey = process.env.ANTHROPIC_API_KEY if (apiKey) { try { @@ -172,3 +179,89 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { return new Response(stream) } + +/** Parse an OpenCode model string ("providerID/modelID") into its parts */ +function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined { + if (!model || !model.includes('/')) return undefined + const idx = model.indexOf('/') + return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) } +} + +/** Stream via OpenCode SDK (connects to a running OpenCode server) */ +function streamViaOpenCode(body: ChatBody, model?: string) { + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + const pingTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) + } catch { /* stream already closed */ } + }, KEEPALIVE_INTERVAL_MS) + + let ocServer: { close(): void } | undefined + try { + const { createOpencode } = await import('@opencode-ai/sdk/v2') + const oc = await createOpencode() + ocServer = oc.server + + // Create a session for this conversation + const { data: session, error: sessionError } = await oc.client.session.create({ + title: 'OpenPencil Chat', + }) + if (sessionError || !session) { + throw new Error('Failed to create OpenCode session') + } + + // Inject system prompt as context (no AI reply) + await oc.client.session.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: 'text', text: body.system }], + }) + + // Build prompt from the last user message + const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user') + const prompt = lastUserMsg?.content ?? '' + + const parsed = parseOpenCodeModel(model) + + // Send prompt and await full response + const { data: result, error: promptError } = await oc.client.session.prompt({ + sessionID: session.id, + ...(parsed ? { model: parsed } : {}), + parts: [{ type: 'text', text: prompt }], + }) + + if (promptError) { + throw new Error('OpenCode prompt failed') + } + + // Extract text from response parts + clearInterval(pingTimer) + if (result?.parts) { + for (const part of result.parts) { + if (part.type === 'text' && 'text' in part) { + const data = JSON.stringify({ type: 'text', content: part.text }) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } + } + } + + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), + ) + } catch (error) { + const content = error instanceof Error ? error.message : 'Unknown error' + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), + ) + } finally { + ocServer?.close() + clearInterval(pingTimer) + controller.close() + } + }, + }) + + return new Response(stream) +} diff --git a/server/api/ai/connect-agent.ts b/server/api/ai/connect-agent.ts index 4ccde6bc4..1b618cdf4 100644 --- a/server/api/ai/connect-agent.ts +++ b/server/api/ai/connect-agent.ts @@ -2,7 +2,7 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3' import type { GroupedModel } from '../../../src/types/agent-settings' interface ConnectBody { - agent: 'claude-code' | 'codex-cli' + agent: 'claude-code' | 'codex-cli' | 'opencode' } interface ConnectResult { @@ -31,6 +31,10 @@ export default defineEventHandler(async (event) => { return connectCodexCli() } + if (body.agent === 'opencode') { + return connectOpenCode() + } + return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult }) @@ -151,3 +155,54 @@ async function connectCodexCli(): Promise { return { connected: false, models: [], error: msg } } } + +/** Connect to OpenCode via createOpencode() and fetch its configured providers/models */ +async function connectOpenCode(): Promise { + try { + const { createOpencode } = await import('@opencode-ai/sdk/v2') + const oc = await createOpencode() + + const { data, error } = await oc.client.config.providers() + oc.server.close() + + if (error) { + return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' } + } + + const models: GroupedModel[] = [] + for (const provider of data?.providers ?? []) { + if (!provider.models) continue + for (const [, model] of Object.entries(provider.models)) { + models.push({ + value: `${provider.id}/${model.id}`, + displayName: model.name || model.id, + description: `via ${provider.name || provider.id}`, + provider: 'opencode' as const, + }) + } + } + + if (models.length === 0) { + return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' } + } + + return { connected: true, models } + } catch (error) { + const raw = error instanceof Error ? error.message : 'Failed to connect' + return { connected: false, models: [], error: friendlyOpenCodeError(raw) } + } +} + +/** Map OpenCode connection errors to user-friendly messages */ +function friendlyOpenCodeError(raw: string): string { + if (/ECONNREFUSED/i.test(raw)) { + return 'OpenCode server not running. Start it with "opencode" in your terminal first.' + } + if (/not found|ENOENT/i.test(raw)) { + return 'OpenCode CLI not found. Please install it first.' + } + if (/timed?\s*out/i.test(raw)) { + return 'Connection timed out. Please try again.' + } + return raw +} diff --git a/server/api/ai/generate.ts b/server/api/ai/generate.ts index 847f60430..72c311e60 100644 --- a/server/api/ai/generate.ts +++ b/server/api/ai/generate.ts @@ -4,6 +4,7 @@ interface GenerateBody { system: string message: string model?: string + provider?: string } /** @@ -19,6 +20,12 @@ export default defineEventHandler(async (event) => { return { error: 'Missing required fields: system, message' } } + // Explicit provider routing + if (body.provider === 'opencode') { + return generateViaOpenCode(body, body.model) + } + + // Default: existing behavior (backward-compatible) const apiKey = process.env.ANTHROPIC_API_KEY if (apiKey) { try { @@ -88,3 +95,62 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise< return { error: message } } } + +/** Generate via OpenCode SDK (connects to a running OpenCode server) */ +async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> { + let ocServer: { close(): void } | undefined + try { + const { createOpencode } = await import('@opencode-ai/sdk/v2') + const oc = await createOpencode() + ocServer = oc.server + + const { data: session, error: sessionError } = await oc.client.session.create({ + title: 'OpenPencil Generate', + }) + if (sessionError || !session) { + return { error: 'Failed to create OpenCode session' } + } + + // Inject system prompt as context (no AI reply) + await oc.client.session.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: 'text', text: body.system }], + }) + + // Parse model string ("providerID/modelID") + let modelOption: { providerID: string; modelID: string } | undefined + if (model && model.includes('/')) { + const idx = model.indexOf('/') + modelOption = { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) } + } + + // Send main prompt and await full response + const { data: result, error: promptError } = await oc.client.session.prompt({ + sessionID: session.id, + ...(modelOption ? { model: modelOption } : {}), + parts: [{ type: 'text', text: body.message }], + }) + + if (promptError) { + return { error: 'OpenCode generation failed' } + } + + // Extract text from response parts + const texts: string[] = [] + if (result?.parts) { + for (const part of result.parts) { + if (part.type === 'text' && part.text) { + texts.push(part.text) + } + } + } + + return { text: texts.join('') } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { error: message } + } finally { + ocServer?.close() + } +} diff --git a/src/components/editor/top-bar.tsx b/src/components/editor/top-bar.tsx index 333c9c655..1526e2c9a 100644 --- a/src/components/editor/top-bar.tsx +++ b/src/components/editor/top-bar.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react' import ClaudeLogo from '@/components/icons/claude-logo' import OpenAILogo from '@/components/icons/openai-logo' +import OpenCodeLogo from '@/components/icons/opencode-logo' import { Button } from '@/components/ui/button' import { Tooltip, @@ -209,6 +210,7 @@ export default function TopBar() { > + Agents & MCP diff --git a/src/components/icons/opencode-logo.tsx b/src/components/icons/opencode-logo.tsx new file mode 100644 index 000000000..8d4dc3252 --- /dev/null +++ b/src/components/icons/opencode-logo.tsx @@ -0,0 +1,63 @@ +import type { SVGProps } from 'react' + +export default function OpenCodeLogo(props: SVGProps) { + return ( + + + {/* Terminal window frame */} + + {/* Title bar line */} + + {/* Title bar dots */} + + + + {/* Terminal prompt chevron > */} + + {/* Cursor line */} + + + + + + + + + ) +} diff --git a/src/components/panels/ai-chat-panel.tsx b/src/components/panels/ai-chat-panel.tsx index f059a6038..b711d28c4 100644 --- a/src/components/panels/ai-chat-panel.tsx +++ b/src/components/panels/ai-chat-panel.tsx @@ -23,11 +23,13 @@ import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types' import type { AIProviderType } from '@/types/agent-settings' import ClaudeLogo from '@/components/icons/claude-logo' import OpenAILogo from '@/components/icons/openai-logo' +import OpenCodeLogo from '@/components/icons/opencode-logo' import ChatMessage from './chat-message' const PROVIDER_ICON: Record = { anthropic: ClaudeLogo, openai: OpenAILogo, + opencode: OpenCodeLogo, } const QUICK_ACTIONS = [ @@ -221,8 +223,12 @@ function useChatHandlers() { } else { // --- CHAT MODE --- chatHistory.push({ role: 'user', content: fullUserMessage }) + // Resolve which provider the currently selected model belongs to + const currentProvider = useAIStore.getState().modelGroups.find((g) => + g.models.some((m) => m.value === model), + )?.provider let chatThinking = false - for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model)) { + for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model, undefined, currentProvider)) { if (chunk.type === 'thinking') { // Show a brief thinking indicator so the UI isn't stuck on empty if (!chatThinking && !accumulated) { @@ -343,6 +349,7 @@ export default function AIChatPanel() { const providerNames: Record = { anthropic: 'Anthropic', openai: 'OpenAI', + opencode: 'OpenCode', } const groups = connectedProviders.map((p) => ({ provider: p, diff --git a/src/components/shared/agent-settings-dialog.tsx b/src/components/shared/agent-settings-dialog.tsx index 6b45893d5..c28bbb8cf 100644 --- a/src/components/shared/agent-settings-dialog.tsx +++ b/src/components/shared/agent-settings-dialog.tsx @@ -8,11 +8,12 @@ import { useAgentSettingsStore } from '@/stores/agent-settings-store' import type { AIProviderType, GroupedModel } from '@/types/agent-settings' import ClaudeLogo from '@/components/icons/claude-logo' import OpenAILogo from '@/components/icons/openai-logo' +import OpenCodeLogo from '@/components/icons/opencode-logo' /** Provider display metadata */ const PROVIDER_META: Record< AIProviderType, - { label: string; description: string; agent: 'claude-code' | 'codex-cli'; Icon: ComponentType> } + { label: string; description: string; agent: 'claude-code' | 'codex-cli' | 'opencode'; Icon: ComponentType> } > = { anthropic: { label: 'Claude Code', @@ -26,10 +27,16 @@ const PROVIDER_META: Record< agent: 'codex-cli', Icon: OpenAILogo, }, + opencode: { + label: 'OpenCode', + description: 'Connect to local OpenCode server to use 75+ LLM providers', + agent: 'opencode', + Icon: OpenCodeLogo, + }, } async function connectAgent( - agent: 'claude-code' | 'codex-cli', + agent: 'claude-code' | 'codex-cli' | 'opencode', ): Promise<{ connected: boolean; models: GroupedModel[]; error?: string }> { try { const res = await fetch('/api/ai/connect-agent', { @@ -194,6 +201,7 @@ export default function AgentSettingsDialog() {
+
diff --git a/src/services/ai/ai-service.ts b/src/services/ai/ai-service.ts index b5eb993ef..def4e9122 100644 --- a/src/services/ai/ai-service.ts +++ b/src/services/ai/ai-service.ts @@ -18,6 +18,7 @@ export async function* streamChat( messages: Array<{ role: 'user' | 'assistant'; content: string }>, model?: string, options?: StreamChatOptions, + provider?: string, ): AsyncGenerator { const hardTimeoutMs = Math.max(10_000, options?.hardTimeoutMs ?? DEFAULT_STREAM_HARD_TIMEOUT_MS) const noTextTimeoutMs = Math.max(10_000, options?.noTextTimeoutMs ?? DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS) @@ -50,7 +51,7 @@ export async function* streamChat( const response = await fetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ system: systemPrompt, messages, model }), + body: JSON.stringify({ system: systemPrompt, messages, model, provider }), signal: controller.signal, }) @@ -213,6 +214,7 @@ export async function generateCompletion( systemPrompt: string, userMessage: string, model?: string, + provider?: string, ): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), DEFAULT_GENERATE_TIMEOUT_MS) @@ -222,7 +224,7 @@ export async function generateCompletion( response = await fetch('/api/ai/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ system: systemPrompt, message: userMessage, model }), + body: JSON.stringify({ system: systemPrompt, message: userMessage, model, provider }), signal: controller.signal, }) } catch (error) { diff --git a/src/stores/agent-settings-store.ts b/src/stores/agent-settings-store.ts index 3d89e2d5b..01c3dfc59 100644 --- a/src/stores/agent-settings-store.ts +++ b/src/stores/agent-settings-store.ts @@ -43,6 +43,13 @@ const DEFAULT_PROVIDERS: Record = { connectionMethod: null, models: [], }, + opencode: { + type: 'opencode', + displayName: 'OpenCode', + isConnected: false, + connectionMethod: null, + models: [], + }, } const DEFAULT_MCP_INTEGRATIONS: MCPCliIntegration[] = [ diff --git a/src/types/agent-settings.ts b/src/types/agent-settings.ts index 2c668d20a..b6bef98ea 100644 --- a/src/types/agent-settings.ts +++ b/src/types/agent-settings.ts @@ -1,10 +1,10 @@ -export type AIProviderType = 'anthropic' | 'openai' +export type AIProviderType = 'anthropic' | 'openai' | 'opencode' export interface AIProviderConfig { type: AIProviderType displayName: string isConnected: boolean - connectionMethod: 'claude-code' | 'codex-cli' | null + connectionMethod: 'claude-code' | 'codex-cli' | 'opencode' | null /** Models fetched when the user connects this provider */ models: GroupedModel[] } diff --git a/src/types/opencode-sdk.d.ts b/src/types/opencode-sdk.d.ts new file mode 100644 index 000000000..4c90ea1a0 --- /dev/null +++ b/src/types/opencode-sdk.d.ts @@ -0,0 +1,82 @@ +/** + * Minimal type declarations for @opencode-ai/sdk. + * The package's export map points to non-existent paths (dist/ vs dist/src/). + * A postinstall script patches this, but we keep declarations as fallback. + */ + +interface OpencodeClient { + config: { + providers(options?: unknown): Promise<{ + data: { + providers: OpencodeProvider[] + default: Record + } + error: unknown + }> + } + session: { + create(options?: { + body?: { parentID?: string; title?: string } + }): Promise<{ + data: OpencodeSession | undefined + error: unknown + }> + prompt(options: { + path: { id: string } + body: { + model?: { providerID: string; modelID: string } + noReply?: boolean + parts: Array<{ type: string; text: string }> + } + }): Promise<{ + data: + | { + info: Record + parts: Array<{ type: string; text?: string } & Record> + } + | undefined + error: unknown + }> + } +} + +interface OpencodeProvider { + id: string + name: string + models: Record +} + +interface OpencodeModel { + id: string + name: string + providerID: string +} + +interface OpencodeSession { + id: string + title: string +} + +declare module '@opencode-ai/sdk' { + export function createOpencode(options?: { + hostname?: string + port?: number + signal?: AbortSignal + timeout?: number + }): Promise<{ + client: OpencodeClient + server: { url: string; close(): void } + }> + + export function createOpencodeClient(config?: { + baseUrl?: string + directory?: string + }): OpencodeClient +} + +declare module '@opencode-ai/sdk/client' { + export function createOpencodeClient(config?: { + baseUrl?: string + directory?: string + }): OpencodeClient +} diff --git a/vite.config.ts b/vite.config.ts index 4628a4f47..bd1f32b05 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,10 +13,13 @@ const config = defineConfig({ '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, + ssr: { + external: ['@opencode-ai/sdk'], + }, plugins: [ devtools(), nitro({ - rollupConfig: { external: [/^@sentry\//] }, + rollupConfig: { external: [/^@sentry\//, /^@opencode-ai\//] }, serverDir: './server', }), // this is the plugin that enables path aliases