feat(opencode): integrate OpenCode SDK for enhanced AI interactions

- Add support for OpenCode as a new AI provider, allowing users to connect to a local OpenCode server.
- Implement new API endpoints for generating and streaming chat responses via OpenCode.
- Update UI components to include OpenCode in provider selection and display.
- Introduce OpenCode logo and enhance the agent settings dialog to manage OpenCode connections.
- Refactor existing chat and generation logic to accommodate provider-specific routing.
- Include type definitions for OpenCode SDK to ensure type safety and improve developer experience.
This commit is contained in:
Kayshen-X 2026-02-20 18:51:05 +08:00
parent 61441d4014
commit 05b4cd6e49
14 changed files with 401 additions and 9 deletions

View file

@ -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=="],

View file

@ -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",

View file

@ -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)
}

View file

@ -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<ConnectResult> {
return { connected: false, models: [], error: msg }
}
}
/** Connect to OpenCode via createOpencode() and fetch its configured providers/models */
async function connectOpenCode(): Promise<ConnectResult> {
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
}

View file

@ -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()
}
}

View file

@ -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() {
>
<ClaudeLogo className="w-4 h-4" />
<OpenAILogo className="w-4 h-4 -ml-1" />
<OpenCodeLogo className="w-4 h-4 -ml-1" />
<span className="hidden sm:inline">Agents & MCP</span>
</Button>
</TooltipTrigger>

View file

@ -0,0 +1,63 @@
import type { SVGProps } from 'react'
export default function OpenCodeLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#oc-clip)">
{/* Terminal window frame */}
<rect
x="12"
y="14"
width="40"
height="36"
rx="4"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
/>
{/* Title bar line */}
<line
x1="12"
y1="22"
x2="52"
y2="22"
stroke="currentColor"
strokeWidth="2"
/>
{/* Title bar dots */}
<circle cx="18" cy="18" r="1.5" fill="currentColor" />
<circle cx="23" cy="18" r="1.5" fill="currentColor" />
<circle cx="28" cy="18" r="1.5" fill="currentColor" />
{/* Terminal prompt chevron > */}
<path
d="M18 29L26 35L18 41"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
{/* Cursor line */}
<line
x1="30"
y1="40"
x2="44"
y2="40"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
<defs>
<clipPath id="oc-clip">
<rect width="40" height="40" fill="white" transform="translate(12 12)" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -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<AIProviderType, typeof ClaudeLogo> = {
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<AIProviderType, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode',
}
const groups = connectedProviders.map((p) => ({
provider: p,

View file

@ -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<SVGProps<SVGSVGElement>> }
{ label: string; description: string; agent: 'claude-code' | 'codex-cli' | 'opencode'; Icon: ComponentType<SVGProps<SVGSVGElement>> }
> = {
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() {
<div className="space-y-2">
<ProviderCard type="anthropic" />
<ProviderCard type="openai" />
<ProviderCard type="opencode" />
</div>
</div>

View file

@ -18,6 +18,7 @@ export async function* streamChat(
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
model?: string,
options?: StreamChatOptions,
provider?: string,
): AsyncGenerator<AIStreamChunk> {
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<string> {
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) {

View file

@ -43,6 +43,13 @@ const DEFAULT_PROVIDERS: Record<AIProviderType, AIProviderConfig> = {
connectionMethod: null,
models: [],
},
opencode: {
type: 'opencode',
displayName: 'OpenCode',
isConnected: false,
connectionMethod: null,
models: [],
},
}
const DEFAULT_MCP_INTEGRATIONS: MCPCliIntegration[] = [

View file

@ -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[]
}

82
src/types/opencode-sdk.d.ts vendored Normal file
View file

@ -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<string, string>
}
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<string, unknown>
parts: Array<{ type: string; text?: string } & Record<string, unknown>>
}
| undefined
error: unknown
}>
}
}
interface OpencodeProvider {
id: string
name: string
models: Record<string, OpencodeModel>
}
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
}

View file

@ -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