mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
* fix(ai): add icon name aliases and fix multi-path SVG concatenation Add 55+ common icon name aliases (burger→hamburger, sushi→fish, etc.) to both client icon-resolver and server icon API for robust AI-generated icon resolution. Register Lucide's own aliases for broader coverage. Fix SVG path concatenation bug where joining multiple <path> d-values caused incorrect rendering — a standalone <path> treats initial lowercase "m" as absolute, but after concatenation it becomes relative to the previous sub-path endpoint. Now ensures each sub-path starts with absolute "M". Add tryAsyncIconFontResolution for icon_font nodes that miss local lookup — fetches from server API, caches result, and triggers canvas re-render. * fix(canvas): preserve badge/overlay absolute positioning in auto-layout Add isBadgeOverlayNode() detector for badge, indicator, notification-dot, and overlay nodes. These nodes now retain their x/y coordinates instead of being stripped by layout sanitization. Update computeLayoutPositions to exclude badge nodes from the layout flow — they keep absolute positioning and render on top (prepended for correct z-order in reverse iteration). * fix(ai): prevent duplicate canvas objects and fix emoji-to-icon pipeline Streaming path: add ensureUniqueNodeIds before inserting nodes to prevent ID collisions across multiple AI generations. Track newly inserted IDs so subsequent streaming nodes don't collide either. Canvas sync: deduplicate Fabric objects sharing the same penNodeId — keep only the one tracked in objMap, remove stale duplicates. Badge nodes: use shared isBadgeOverlayNode() for z-order insertion and skip x/y stripping in layout parents. Fix emoji-to-icon pipeline: re-run applyIconPathResolution after applyNoEmojiIconHeuristic converts emoji text nodes to path nodes, so the icon resolver can match by name (e.g. "Pizza Emoji Path" → pizza). * fix(canvas): add async icon resolution fallback for icon_font nodes When lookupIconByName fails locally, queue tryAsyncIconFontResolution to fetch from server API. Cache result in ICON_PATH_MAP and trigger canvas re-render via store update. Store iconFontName and iconStyle on Fabric object for sync tracking. * fix(ai): strengthen emoji ban in prompts and improve orchestrator defaults Update all AI prompts to explicitly ban emoji characters with concrete examples and redirect to icon_font nodes instead of the previously incorrect "path nodes" guidance. Add z-order rule to orchestrator prompt: overlay elements must come before content they overlap. Add padding support to OrchestratorPlan rootFrame type. Default mobile root frame gap to 16 for consistent spacing. * feat(electron): add publisher name to Windows build configuration Updated the `electron-builder.yml` to include a publisher name for Windows builds, enhancing the identification of the application during installation. Additionally, revised the README files across multiple languages to reflect the new project description and features, emphasizing OpenPencil as the world's first AI-native open-source vector design tool with concurrent agent teams and design-as-code capabilities. --------- Co-authored-by: Fini <fini.yang@gmail.com>
749 lines
28 KiB
TypeScript
749 lines
28 KiB
TypeScript
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
|
import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
|
import { runCodexExec } from '../../utils/codex-client'
|
|
import {
|
|
buildClaudeAgentEnv,
|
|
getClaudeAgentDebugFilePath,
|
|
} from '../../utils/resolve-claude-agent-env'
|
|
|
|
/** Pattern for detecting sensitive data in debug log output */
|
|
export const SENSITIVE_LOG_PATTERN = /ANTHROPIC_API_KEY=|Authorization:\s*Bearer|api[_-]?key\s*[:=]/i
|
|
|
|
/** Allowed media types for image attachments */
|
|
export const ALLOWED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
|
|
|
|
/** Resolve file extension from media type, falling back to 'png' for disallowed types */
|
|
export function resolveMediaExtension(mediaType: string): string {
|
|
return ALLOWED_MEDIA_TYPES.has(mediaType) ? mediaType.split('/')[1] : 'png'
|
|
}
|
|
|
|
interface ChatAttachmentWire {
|
|
name: string
|
|
mediaType: string
|
|
data: string // base64
|
|
}
|
|
|
|
interface ChatBody {
|
|
system: string
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string; attachments?: ChatAttachmentWire[] }>
|
|
model?: string
|
|
provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot'
|
|
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
|
|
thinkingBudgetTokens?: number
|
|
effort?: 'low' | 'medium' | 'high' | 'max'
|
|
}
|
|
|
|
async function readDebugTail(path?: string, maxLines = 40): Promise<string[] | undefined> {
|
|
if (!path) return undefined
|
|
try {
|
|
const raw = await readFile(path, 'utf-8')
|
|
const lines = raw.split('\n').filter((l) => l.trim().length > 0)
|
|
const sanitized = lines.filter(l => !SENSITIVE_LOG_PATTERN.test(l))
|
|
return sanitized.slice(-maxLines)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | undefined {
|
|
if (!/process exited with code 1/i.test(rawError)) return undefined
|
|
if (!debugTail || debugTail.length === 0) return undefined
|
|
const text = debugTail.join('\n')
|
|
|
|
const hints: string[] = []
|
|
if (/Failed to save config with lock: Error: EPERM|operation not permitted, .*\.claude\.json/i.test(text)) {
|
|
hints.push('Claude Code cannot write ~/.claude.json in the current runtime (permission denied).')
|
|
}
|
|
if (/Connection error|Could not resolve host|Failed to connect/i.test(text)) {
|
|
hints.push('Upstream API connection failed (check proxy/DNS/network reachability to your ANTHROPIC_BASE_URL).')
|
|
}
|
|
if (/ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false/i.test(text)) {
|
|
hints.push('No API auth header detected by Claude runtime; verify token/header env mapping.')
|
|
}
|
|
|
|
if (hints.length === 0) return undefined
|
|
return `${rawError}\n${hints.join(' ')}`
|
|
}
|
|
|
|
/**
|
|
* Streaming chat endpoint.
|
|
* Routes to the appropriate provider SDK based on the `provider` field.
|
|
* Requires explicit provider and model; no fallback routing.
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
const body = await readBody<ChatBody>(event)
|
|
|
|
if (!body?.messages || !body?.system) {
|
|
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
|
return { error: 'Missing required fields: system, messages' }
|
|
}
|
|
if (!body.provider) {
|
|
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
|
return { error: 'Missing provider. Provider fallback is disabled.' }
|
|
}
|
|
if (!body.model?.trim()) {
|
|
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
|
return { error: 'Missing model. Model fallback is disabled.' }
|
|
}
|
|
if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot') {
|
|
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
|
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }
|
|
}
|
|
|
|
setResponseHeaders(event, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
})
|
|
|
|
if (body.provider === 'anthropic') return streamViaAgentSDK(body, body.model)
|
|
if (body.provider === 'opencode') return streamViaOpenCode(body, body.model)
|
|
if (body.provider === 'copilot') return streamViaCopilot(body, body.model)
|
|
return streamViaCodex(body, body.model)
|
|
})
|
|
|
|
// Keep-alive ping interval (ms) — prevents client timeout while waiting for API TTFT
|
|
const KEEPALIVE_INTERVAL_MS = 15_000
|
|
function getAgentThinkingConfig(body: ChatBody):
|
|
| { type: 'adaptive' | 'disabled' }
|
|
| { type: 'enabled'; budgetTokens?: number }
|
|
| undefined {
|
|
if (!body.thinkingMode) return undefined
|
|
if (body.thinkingMode === 'enabled') {
|
|
return { type: 'enabled', budgetTokens: body.thinkingBudgetTokens }
|
|
}
|
|
return { type: body.thinkingMode }
|
|
}
|
|
|
|
/**
|
|
* Save base64 attachments to temp files. Returns { tempDir, files[] } — caller must clean up tempDir.
|
|
*
|
|
* When `insideProject` is true, files are saved under `.openpencil-tmp/` in the
|
|
* current working directory so that Claude Code Agent SDK (which restricts reads
|
|
* to the project directory in plan mode) can access them.
|
|
*/
|
|
async function saveAttachmentsToTempFiles(
|
|
attachments: ChatAttachmentWire[],
|
|
insideProject = false,
|
|
): Promise<{ tempDir: string; files: string[] }> {
|
|
let tempDir: string
|
|
if (insideProject) {
|
|
const { mkdirSync, chmodSync } = await import('node:fs')
|
|
const baseDir = join(process.cwd(), '.openpencil-tmp')
|
|
mkdirSync(baseDir, { recursive: true, mode: 0o700 })
|
|
chmodSync(baseDir, 0o700)
|
|
tempDir = await mkdtemp(join(baseDir, 'attach-'))
|
|
} else {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'openpencil-attach-'))
|
|
}
|
|
const files: string[] = []
|
|
for (const att of attachments) {
|
|
const ext = resolveMediaExtension(att.mediaType)
|
|
const filePath = join(tempDir, `${files.length}.${ext}`)
|
|
await writeFile(filePath, Buffer.from(att.data, 'base64'))
|
|
files.push(filePath)
|
|
}
|
|
return { tempDir, files }
|
|
}
|
|
|
|
/** Collect all attachments from the last user message */
|
|
function getLastUserAttachments(body: ChatBody): ChatAttachmentWire[] {
|
|
const lastUser = [...body.messages].reverse().find((m) => m.role === 'user')
|
|
return lastUser?.attachments ?? []
|
|
}
|
|
|
|
/**
|
|
* Strip "NEVER use tools" and similar instructions from system prompt
|
|
* when we need Claude Code Agent SDK to use its Read tool for image analysis.
|
|
*/
|
|
function stripNoToolsRestriction(systemPrompt: string): string {
|
|
return systemPrompt
|
|
.replace(/^.*NEVER use tools.*$/gim, '')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
}
|
|
|
|
/** Stream via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
|
|
function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const encoder = new TextEncoder()
|
|
// Send keep-alive pings until the first real chunk arrives
|
|
const pingTimer = setInterval(() => {
|
|
try {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
|
|
} catch { /* stream already closed */ }
|
|
}, KEEPALIVE_INTERVAL_MS)
|
|
let debugFile: string | undefined
|
|
let attachTempDir: string | undefined
|
|
|
|
try {
|
|
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
|
|
|
// Build prompt from the last user message
|
|
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
|
|
let prompt = lastUserMsg?.content ?? ''
|
|
|
|
// If the last user message has image attachments, save to temp files
|
|
// inside the project directory so Claude Code has read permission.
|
|
const attachments = getLastUserAttachments(body)
|
|
const hasImageAttachments = attachments.length > 0
|
|
if (hasImageAttachments) {
|
|
const saved = await saveAttachmentsToTempFiles(attachments, true)
|
|
attachTempDir = saved.tempDir
|
|
const imageRefs = saved.files.map((f) =>
|
|
`First, use the Read tool to read the image file at "${f}". Then analyze it and respond to the user.`,
|
|
).join('\n')
|
|
prompt = imageRefs + '\n\n' + (prompt || 'Describe what you see in the image.')
|
|
}
|
|
|
|
// Remove CLAUDECODE env to allow running from within a CC terminal
|
|
const env = buildClaudeAgentEnv()
|
|
debugFile = getClaudeAgentDebugFilePath()
|
|
|
|
const claudePath = resolveClaudeCli()
|
|
const thinking = getAgentThinkingConfig(body)
|
|
|
|
// When images are attached, strip the "NEVER use tools" restriction from
|
|
// the system prompt so Claude Code will use its Read tool to view images.
|
|
const effectiveSystemPrompt = hasImageAttachments
|
|
? stripNoToolsRestriction(body.system)
|
|
: body.system
|
|
|
|
// When images are attached, use result-based flow (like validate.ts):
|
|
// let Claude Code read the image via its Read tool internally, then
|
|
// only emit the final result text. This avoids streaming intermediate
|
|
// tool-use preamble like "I need to read the file first".
|
|
if (hasImageAttachments) {
|
|
const runImageQuery = async (): Promise<string> => {
|
|
const q = query({
|
|
prompt,
|
|
options: {
|
|
systemPrompt: effectiveSystemPrompt,
|
|
...(model ? { model } : {}),
|
|
maxTurns: 3,
|
|
plugins: [],
|
|
permissionMode: 'plan',
|
|
persistSession: false,
|
|
...(body.effort ? { effort: body.effort } : {}),
|
|
...(thinking ? { thinking } : {}),
|
|
env,
|
|
...(debugFile ? { debugFile } : {}),
|
|
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
|
},
|
|
})
|
|
|
|
try {
|
|
for await (const message of q) {
|
|
if (message.type === 'result') {
|
|
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
|
|
if (message.subtype === 'success' && !isErrorResult) {
|
|
return message.result ?? ''
|
|
}
|
|
const errors = 'errors' in message ? (message.errors as string[]) : []
|
|
const resultText = 'result' in message ? String(message.result ?? '') : ''
|
|
const errContent = errors.join('; ') || resultText || `Query ended with: ${message.subtype}`
|
|
throw new Error(errContent)
|
|
}
|
|
}
|
|
return ''
|
|
} finally {
|
|
q.close()
|
|
}
|
|
}
|
|
|
|
const resultText = await runImageQuery()
|
|
|
|
clearInterval(pingTimer)
|
|
if (resultText) {
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'text', content: resultText })}\n\n`),
|
|
)
|
|
}
|
|
} else {
|
|
// Normal text-only chat: stream partial messages as before
|
|
const runQuery = async () => {
|
|
const q = query({
|
|
prompt,
|
|
options: {
|
|
systemPrompt: effectiveSystemPrompt,
|
|
...(model ? { model } : {}),
|
|
maxTurns: 1,
|
|
includePartialMessages: true,
|
|
tools: [],
|
|
plugins: [],
|
|
permissionMode: 'plan',
|
|
persistSession: false,
|
|
...(body.effort ? { effort: body.effort } : {}),
|
|
...(thinking ? { thinking } : {}),
|
|
env,
|
|
...(debugFile ? { debugFile } : {}),
|
|
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
|
},
|
|
})
|
|
|
|
try {
|
|
for await (const message of q) {
|
|
if (message.type === 'stream_event') {
|
|
const ev = message.event
|
|
if (ev.type === 'content_block_delta') {
|
|
if (ev.delta.type === 'text_delta') {
|
|
clearInterval(pingTimer)
|
|
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
} else if (ev.delta.type === 'thinking_delta') {
|
|
// Keep pings alive during thinking — only stop on text output
|
|
const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking })
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
}
|
|
}
|
|
} else if (message.type === 'result') {
|
|
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
|
|
if (message.subtype !== 'success' || isErrorResult) {
|
|
const errors = 'errors' in message ? (message.errors as string[]) : []
|
|
const resultText = 'result' in message ? String(message.result ?? '') : ''
|
|
const content = errors.join('; ') || resultText || `Query ended with: ${message.subtype}`
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
q.close()
|
|
}
|
|
}
|
|
|
|
await runQuery()
|
|
}
|
|
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
|
|
)
|
|
} catch (error) {
|
|
const rawContent = error instanceof Error ? error.message : 'Unknown error'
|
|
const tail = await readDebugTail(debugFile)
|
|
const hintedContent = buildClaudeExitHint(rawContent, tail)
|
|
const content = hintedContent ?? rawContent
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
|
)
|
|
} finally {
|
|
clearInterval(pingTimer)
|
|
if (attachTempDir) {
|
|
rm(attachTempDir, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream)
|
|
}
|
|
|
|
/** Error name → user-friendly label mapping */
|
|
const OPENCODE_ERROR_LABELS: Record<string, string> = {
|
|
APIError: 'API error',
|
|
ProviderAuthError: 'Authentication failed',
|
|
UnknownError: 'Unknown error',
|
|
MessageOutputLengthError: 'Response too long',
|
|
MessageAbortedError: 'Request aborted',
|
|
StructuredOutputError: 'Output format error',
|
|
ContextOverflowError: 'Context too long',
|
|
}
|
|
|
|
/**
|
|
* Extract a human-readable message from an OpenCode error object.
|
|
* Handles structured errors like { name: "APIError", data: { message: "..." } }
|
|
* and nested JSON in message strings.
|
|
*/
|
|
export function formatOpenCodeError(error: unknown): string {
|
|
if (!error) return 'Unknown error'
|
|
if (typeof error === 'string') return error
|
|
|
|
const err = error as Record<string, any>
|
|
|
|
// Structured OpenCode error: { name, data: { message, ... } }
|
|
if (err.name && err.data?.message) {
|
|
const label = OPENCODE_ERROR_LABELS[err.name] ?? err.name
|
|
let msg: string = err.data.message
|
|
|
|
// Try to extract nested error message from JSON in the message string
|
|
// e.g. 'Unauthorized: {"error":{"code":"invalid_api_key","message":"invalid access token"}}'
|
|
const jsonStart = msg.indexOf('{')
|
|
if (jsonStart > 0) {
|
|
try {
|
|
const nested = JSON.parse(msg.slice(jsonStart))
|
|
const nestedMsg = nested?.error?.message ?? nested?.message
|
|
if (nestedMsg) {
|
|
const prefix = msg.slice(0, jsonStart).replace(/:\s*$/, '').trim()
|
|
msg = prefix ? `${prefix}: ${nestedMsg}` : nestedMsg
|
|
}
|
|
} catch { /* not JSON, use as-is */ }
|
|
}
|
|
|
|
return `${label} — ${msg}`
|
|
}
|
|
|
|
// Plain { message } object
|
|
if (err.message) return err.message
|
|
|
|
// Fallback: truncated JSON
|
|
const json = JSON.stringify(error)
|
|
return json.length > 200 ? json.slice(0, 200) + '…' : json
|
|
}
|
|
|
|
/** 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) }
|
|
}
|
|
|
|
function mapOpenCodeEffort(
|
|
effort?: 'low' | 'medium' | 'high' | 'max',
|
|
): 'low' | 'medium' | 'high' | undefined {
|
|
if (!effort) return undefined
|
|
if (effort === 'max') return 'high'
|
|
return effort
|
|
}
|
|
|
|
function buildOpenCodeReasoning(
|
|
body: ChatBody,
|
|
): Record<string, unknown> | undefined {
|
|
const reasoning: Record<string, unknown> = {}
|
|
const effort = mapOpenCodeEffort(body.effort)
|
|
if (effort) {
|
|
reasoning.effort = effort
|
|
}
|
|
if (body.thinkingMode === 'enabled') {
|
|
reasoning.enabled = true
|
|
} else if (body.thinkingMode === 'disabled') {
|
|
reasoning.enabled = false
|
|
}
|
|
if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) {
|
|
reasoning.budgetTokens = body.thinkingBudgetTokens
|
|
}
|
|
return Object.keys(reasoning).length > 0 ? reasoning : undefined
|
|
}
|
|
|
|
/** Wrap an async generator with a timeout — yields values until timeout fires */
|
|
async function* streamWithTimeout<T>(
|
|
stream: AsyncGenerator<T>,
|
|
timeoutPromise: Promise<{ done: true; value: undefined }>,
|
|
): AsyncGenerator<T> {
|
|
while (true) {
|
|
const result = await Promise.race([
|
|
stream.next(),
|
|
timeoutPromise,
|
|
]) as IteratorResult<T>
|
|
if (result.done) break
|
|
yield result.value
|
|
}
|
|
}
|
|
|
|
function streamViaCodex(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 attachTempDir: string | undefined
|
|
try {
|
|
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
|
|
const prompt = lastUserMsg?.content ?? ''
|
|
|
|
// Save image attachments to temp files for Codex CLI
|
|
const attachments = getLastUserAttachments(body)
|
|
let imageFiles: string[] | undefined
|
|
if (attachments.length > 0) {
|
|
const saved = await saveAttachmentsToTempFiles(attachments)
|
|
attachTempDir = saved.tempDir
|
|
imageFiles = saved.files
|
|
}
|
|
|
|
const result = await runCodexExec(prompt, {
|
|
model,
|
|
systemPrompt: body.system,
|
|
thinkingMode: body.thinkingMode,
|
|
thinkingBudgetTokens: body.thinkingBudgetTokens,
|
|
effort: body.effort,
|
|
imageFiles,
|
|
})
|
|
|
|
clearInterval(pingTimer)
|
|
if (result.error) {
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', content: result.error })}\n\n`),
|
|
)
|
|
return
|
|
}
|
|
|
|
if (result.text) {
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'text', content: result.text })}\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 {
|
|
clearInterval(pingTimer)
|
|
if (attachTempDir) {
|
|
rm(attachTempDir, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream)
|
|
}
|
|
|
|
/** Stream via OpenCode SDK using event subscription for real-time streaming */
|
|
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 { getOpencodeClient } = await import('../../utils/opencode-client')
|
|
const oc = await getOpencodeClient()
|
|
const ocClient = oc.client
|
|
ocServer = oc.server
|
|
|
|
// Create a session for this conversation
|
|
const { data: session, error: sessionError } = await ocClient.session.create({
|
|
title: 'OpenPencil Chat',
|
|
})
|
|
if (sessionError || !session) {
|
|
throw new Error(`Failed to create OpenCode session: ${formatOpenCodeError(sessionError)}`)
|
|
}
|
|
|
|
// Inject system prompt as context (no AI reply)
|
|
await ocClient.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)
|
|
if (model && !parsed) {
|
|
console.warn(`[AI] OpenCode: could not parse model string "${model}", sending without model override`)
|
|
}
|
|
|
|
// Build parts array, adding image attachments if present
|
|
const attachments = getLastUserAttachments(body)
|
|
const parts: Array<Record<string, unknown>> = [
|
|
...attachments.map((a) => ({
|
|
type: 'image',
|
|
url: `data:${a.mediaType};base64,${a.data}`,
|
|
})),
|
|
{ type: 'text', text: prompt || 'Analyze these images.' },
|
|
]
|
|
|
|
console.log(`[AI] OpenCode streaming prompt: model=${model}, parsed=${JSON.stringify(parsed)}`)
|
|
|
|
// Build prompt payload with optional model and reasoning
|
|
const promptPayload: Record<string, unknown> = {
|
|
sessionID: session.id,
|
|
...(parsed ? { model: parsed } : {}),
|
|
parts,
|
|
}
|
|
const reasoning = buildOpenCodeReasoning(body)
|
|
if (reasoning) {
|
|
promptPayload.reasoning = reasoning
|
|
}
|
|
|
|
// Subscribe to event stream for real-time deltas
|
|
const eventResult = await ocClient.event.subscribe()
|
|
const eventStream = eventResult.stream
|
|
|
|
// Send prompt asynchronously — response comes via events
|
|
const { error: asyncError } = await ocClient.session.promptAsync(promptPayload as any)
|
|
if (asyncError) {
|
|
const detail = formatOpenCodeError(asyncError)
|
|
console.error('[AI] OpenCode promptAsync error:', detail)
|
|
throw new Error(detail)
|
|
}
|
|
|
|
// Consume event stream, forwarding text deltas to client
|
|
let emittedText = false
|
|
const sessionId = session.id
|
|
const STREAM_TIMEOUT_MS = 180_000
|
|
const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) =>
|
|
setTimeout(() => resolve({ done: true, value: undefined }), STREAM_TIMEOUT_MS),
|
|
)
|
|
|
|
for await (const event of streamWithTimeout(eventStream, timeoutPromise)) {
|
|
if (!event || !('type' in event)) continue
|
|
|
|
const eventType = event.type as string
|
|
|
|
// Stream text deltas for our session
|
|
if (eventType === 'message.part.delta') {
|
|
const props = (event as any).properties
|
|
if (props?.sessionID === sessionId && props.field === 'text') {
|
|
const data = JSON.stringify({ type: 'text', content: props.delta })
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
emittedText = true
|
|
}
|
|
// Forward reasoning deltas as thinking chunks
|
|
if (props?.sessionID === sessionId && props.field === 'reasoning') {
|
|
const data = JSON.stringify({ type: 'thinking', content: props.delta })
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Session went idle — response complete
|
|
if (eventType === 'session.idle') {
|
|
const props = (event as any).properties
|
|
if (props?.sessionID === sessionId) break
|
|
continue
|
|
}
|
|
|
|
// Session error
|
|
if (eventType === 'session.error') {
|
|
const props = (event as any).properties
|
|
if (props?.sessionID === sessionId || !props?.sessionID) {
|
|
const errMsg = formatOpenCodeError(props?.error)
|
|
console.error('[AI] OpenCode session error:', errMsg)
|
|
const data = JSON.stringify({ type: 'error', content: errMsg })
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
clearInterval(pingTimer)
|
|
|
|
if (!emittedText) {
|
|
console.warn('[AI] OpenCode returned no text via streaming events')
|
|
const data = JSON.stringify({ type: 'error', content: 'OpenCode returned an empty response. The model may not have generated any output.' })
|
|
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 {
|
|
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
|
|
releaseOpencodeServer(ocServer)
|
|
clearInterval(pingTimer)
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream)
|
|
}
|
|
|
|
/** Map ChatBody effort to Copilot SDK ReasoningEffort */
|
|
function mapCopilotReasoningEffort(
|
|
effort?: 'low' | 'medium' | 'high' | 'max',
|
|
): 'low' | 'medium' | 'high' | 'xhigh' | undefined {
|
|
if (!effort) return undefined
|
|
if (effort === 'max') return 'xhigh'
|
|
return effort
|
|
}
|
|
|
|
/** Stream via GitHub Copilot SDK (@github/copilot-sdk) */
|
|
function streamViaCopilot(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 copilotClient: { stop(): Promise<unknown> } | undefined
|
|
try {
|
|
const { CopilotClient, approveAll } = await import('@github/copilot-sdk')
|
|
// Use standalone copilot binary to avoid Bun's node:sqlite issue
|
|
const { resolveCopilotCli } = await import('../../utils/copilot-client')
|
|
const cliPath = resolveCopilotCli()
|
|
const client = new CopilotClient({
|
|
autoStart: true,
|
|
...(cliPath ? { cliPath } : {}),
|
|
})
|
|
copilotClient = client
|
|
await client.start()
|
|
|
|
const session = await client.createSession({
|
|
...(model ? { model } : {}),
|
|
streaming: true,
|
|
onPermissionRequest: approveAll,
|
|
systemMessage: { mode: 'replace', content: body.system },
|
|
...(body.effort ? { reasoningEffort: mapCopilotReasoningEffort(body.effort) } : {}),
|
|
})
|
|
|
|
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
|
|
const prompt = lastUserMsg?.content ?? ''
|
|
|
|
// Subscribe to streaming deltas
|
|
session.on('assistant.message_delta', (event) => {
|
|
clearInterval(pingTimer)
|
|
const deltaContent = (event as any).data?.deltaContent ?? ''
|
|
if (deltaContent) {
|
|
const data = JSON.stringify({ type: 'text', content: deltaContent })
|
|
try {
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
} catch { /* stream closed */ }
|
|
}
|
|
})
|
|
|
|
// Wait for completion
|
|
await session.sendAndWait({ prompt }, 120_000)
|
|
await session.destroy()
|
|
|
|
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 {
|
|
clearInterval(pingTimer)
|
|
if (copilotClient) {
|
|
copilotClient.stop().catch(() => {})
|
|
}
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream)
|
|
}
|