* feat(editor): implement Figma import functionality
- Added a new FigmaImportDialog component for importing .fig files.
- Integrated file processing and conversion logic to handle Figma files.
- Enhanced the editor layout to include a button for opening the Figma import dialog.
- Introduced page tabs for better navigation between document pages.
- Updated various components to utilize active page children for rendering and interactions.
- Added new dependencies: fzstd and kiwi-schema for improved functionality.
* fix(ai): harden Claude agent env variable resolution
- Filter empty strings and object values from settings.json env
- Validate ANTHROPIC_CUSTOM_HEADERS is valid JSON before passing
- Remove incorrect ANTHROPIC_CUSTOM_HEADERS auto-generation from auth token
* refactor(ai): remove silent fallback logic, surface errors in AI panel
- Remove direct-stream fallback in generateDesign, delegate to orchestrator
- Remove orchestrator planner fallback plan (buildFallbackPlanFromPrompt)
- Remove sub-agent retry logic, minimal mode retry, and placeholder insertion
- Throw on sub-agent error instead of silently degrading
- Call updateLastMessage in catch block so errors display immediately
- Clean up unused imports, functions, and console.log/warn statements
* fix(ai): add 30s API connect timeout for faster error detection
- Set maxRetries: 0 on Anthropic SDK to prevent retry delays on 429
- Add AbortController-based 30s timeout for initial API connection
- If the provider doesn't respond within 30s, abort and send SSE error
- Refactor streamViaAnthropicSDK with shared send() helper
* fix(editor): improve Figma import visual fidelity
- Extract image files from .fig ZIP archive and resolve by SHA-1 hash
- Map Figma textCase property (UPPER/LOWER/TITLE) to text content
- Convert ellipse nodes with image fills to image nodes
- Decode Figma vector paths and match icons via Lucide icon registry
- Fix z-order: move background rectangles behind content layers
- Set clipContent=true for Figma frames (default clip behavior)
- Unwrap single-artboard pages to use artboard as root frame
* fix(canvas): preserve clipPath during async image load and invalidate cache
- Copy clipPath from placeholder rect to FabricImage in onload callback
- Set obj.dirty=true after applying/removing clipPath so Fabric.js
re-renders with the updated clip region instead of using stale cache
* fix(ai): use page-aware empty frame detection and place new designs as siblings
- Fix isCanvasOnlyEmptyFrame() to use getActivePageChildren() instead of
document.children which is empty after page migration
- When canvas is empty, replace default frame (existing behavior restored)
- When canvas has content, add generated frame as top-level sibling
positioned to the right with 100px gap
- Track generationRootFrameId so height adjustment targets correct frame
- Add WIDTH SELECTION rule: app screens use 375x812 mobile dimensions
- Include selected node dimensions in AI context string
* feat(electron): implement application menu with native actions
- Added a native application menu for macOS and Windows, including File, Edit, View, and Window menus.
- Integrated menu actions for creating, opening, saving files, and editing operations like undo/redo.
- Enhanced the editor layout to handle Electron menu actions through a custom hook.
- Updated icon components in the toolbar and top bar for consistent styling.
- Refactored padding and layout components for improved structure and readability.
* fix(canvas): use page-aware children for selection depth resolution
getSelectableNodeIds() used doc.children which is empty under the
pages architecture. Switch to getActivePageChildren() so canvas
selection correctly syncs to the layers panel.
* fix(panels): adjust property panel sections for image nodes
Hide fill/stroke sections (not applicable to images) and show
corner radius section for image elements.
* feat(canvas): implement image corner radius via clipPath
FabricImage does not support rx/ry natively. Use a rounded-rect
clipPath instead, with objectCaching disabled to ensure immediate
re-render on radius changes.
* fix(canvas): use page-aware forcePageResync for canvas re-sync
The old pattern { ...doc, children: [...doc.children] } only touched
root-level children which are empty under pages architecture. Extract
forcePageResync() into canvas-sync-utils and replace all 7 occurrences
across canvas-object-modified, drag-into-layout, and layout-reorder.
* fix(canvas): preserve image corner radius clipPath during drag
Three issues caused corner radius loss on image drag:
1. Scale baking in object:modified changed FabricImage width from
natural to display dimensions, creating a clipPath size mismatch.
Exclude images from scale baking (like paths/polygons).
2. object:moving unconditionally cleared all clipPaths to avoid stale
frame clipping. Now only clears absolutePositioned (frame) clips,
preserving object-level clips like image corner radius.
3. Post-drag re-sync overwrote the corner radius clipPath with the
parent frame clip. Now skips frame clip when the object already
has its own non-absolutePositioned clipPath.
* feat(ai): enhance chat functionality with image attachments support
- Added support for image attachments in chat messages, allowing users to send images alongside text.
- Implemented temporary file handling for attachments, ensuring they are accessible to the AI processing.
- Updated chat message structure to include attachments, and modified the prompt building logic to reference these images.
- Enhanced the chat panel UI to allow users to select and preview images before sending.
- Introduced a new state management for pending attachments in the AI store.
- Refactored related components to accommodate the new attachment feature, improving user experience and interaction.
* feat(multi-page): introduce multi-page architecture and enhance agent settings UI
- Added multi-page architecture to the document structure, allowing for page CRUD actions such as add, remove, rename, reorder, and duplicate.
- Implemented `activePageId` state management in the canvas store for better page handling.
- Enhanced the top bar with an `AgentStatusButton` to display connected AI providers and their statuses.
- Updated the agent settings dialog to improve the UI for managing AI integrations, including better layout and connection indicators.
- Refactored related components for improved usability and visual consistency.
* docs(README): add contributors section with dynamic contributor image
* feat(ai): improve chat streaming and error handling
- Added functionality to hide the checklist when streaming stops with no completed items.
- Implemented abort controller management to handle user-initiated stops during chat generation.
- Enhanced error handling to prevent displaying errors for user-initiated stops while preserving partial content.
- Updated orchestration and design generation functions to support abort signals, improving responsiveness during streaming.
* fix(canvas): align z-order with layer panel convention (top = front)
Establish consistent z-order: children[0] = top of layer panel = frontmost
on canvas. This matches the standard design tool convention (Figma, Sketch).
- Reverse flattenNodes iteration so children[0] is added last to canvas
- Add z-order reconciliation via moveObjectTo after each sync pass
- Figma import: sort children descending so front items are at children[0]
- addNode defaults to prepend (index 0) so new items appear at top
- duplicateNode inserts clone above original (at idx instead of idx+1)
- drag-reparent inserts at index 0 (frontmost) instead of childCount
- Async image loading preserves z-order via insertAt instead of add
- Add FigmaImportLayoutMode toggle (preserve vs openpencil auto-layout)
* chore: resolve .gitignore merge conflict
* fix(canvas): clamp corner radius to half the element height
* chore: update version to 0.1.0 and modify mac artifact naming in electron-builder configuration
---------
Co-authored-by: Fini <fini.yang@gmail.com>
689 lines
25 KiB
TypeScript
689 lines
25 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'
|
|
|
|
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?: string
|
|
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)
|
|
return lines.slice(-maxLines)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function shouldRetryClaudeWithoutModel(raw: string): boolean {
|
|
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
|
|
}
|
|
|
|
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.
|
|
* Tries ANTHROPIC_API_KEY first (via Anthropic SDK);
|
|
* falls back to local Claude Code (via Agent SDK, uses OAuth login).
|
|
*/
|
|
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' }
|
|
}
|
|
|
|
setResponseHeaders(event, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
})
|
|
|
|
// Explicit provider routing
|
|
if (body.provider === 'opencode') {
|
|
return streamViaOpenCode(body, body.model)
|
|
}
|
|
if (body.provider === 'openai') {
|
|
return streamViaCodex(body, body.model)
|
|
}
|
|
|
|
// Default: existing behavior (backward-compatible)
|
|
const apiKey = process.env.ANTHROPIC_API_KEY
|
|
if (apiKey) {
|
|
try {
|
|
return await streamViaAnthropicSDK(apiKey, body, body.model)
|
|
} catch {
|
|
// SDK not installed or failed — fall back to Agent SDK
|
|
}
|
|
}
|
|
return streamViaAgentSDK(body, body.model)
|
|
})
|
|
|
|
// Keep-alive ping interval (ms) — prevents client timeout while waiting for API TTFT
|
|
const KEEPALIVE_INTERVAL_MS = 15_000
|
|
// Max time to wait for the first SDK event (text/thinking/error).
|
|
// If the API provider doesn't respond within this window, abort and surface
|
|
// a clear error instead of letting the client wait minutes for a timeout.
|
|
const API_CONNECT_TIMEOUT_MS = 30_000
|
|
|
|
function getAnthropicThinkingConfig(body: ChatBody):
|
|
| { type: 'adaptive' | 'disabled' }
|
|
| { type: 'enabled'; budget_tokens: number }
|
|
| undefined {
|
|
if (!body.thinkingMode) return undefined
|
|
if (body.thinkingMode === 'enabled') {
|
|
const budget = Math.max(1024, body.thinkingBudgetTokens ?? 1024)
|
|
return { type: 'enabled', budget_tokens: budget }
|
|
}
|
|
return { type: body.thinkingMode }
|
|
}
|
|
|
|
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 } = await import('node:fs')
|
|
const baseDir = join(process.cwd(), '.openpencil-tmp')
|
|
mkdirSync(baseDir, { recursive: true })
|
|
tempDir = await mkdtemp(join(baseDir, 'attach-'))
|
|
} else {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'openpencil-attach-'))
|
|
}
|
|
const files: string[] = []
|
|
for (const att of attachments) {
|
|
const ext = att.mediaType.split('/')[1] || 'png'
|
|
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')
|
|
}
|
|
|
|
/** Build Anthropic SDK multimodal messages from ChatBody messages */
|
|
function buildAnthropicMessages(body: ChatBody): Array<{ role: string; content: unknown }> {
|
|
return body.messages.map((m) => {
|
|
const attachments = m.attachments ?? []
|
|
if (attachments.length === 0) {
|
|
return { role: m.role, content: m.content }
|
|
}
|
|
const content: Array<Record<string, unknown>> = [
|
|
...attachments.map((a) => ({
|
|
type: 'image',
|
|
source: { type: 'base64', media_type: a.mediaType, data: a.data },
|
|
})),
|
|
{ type: 'text', text: m.content || 'Analyze these images.' },
|
|
]
|
|
return { role: m.role, content }
|
|
})
|
|
}
|
|
|
|
/** Stream via Anthropic SDK (when API key is available) */
|
|
async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: string) {
|
|
const { default: Anthropic } = await import('@anthropic-ai/sdk')
|
|
// Disable automatic retries so auth/balance errors (429) surface immediately
|
|
// instead of waiting through exponential backoff retry cycles.
|
|
const client = new Anthropic({ apiKey, maxRetries: 0 })
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const encoder = new TextEncoder()
|
|
const send = (type: string, content: string) => {
|
|
try {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type, content })}\n\n`))
|
|
} catch { /* stream already closed */ }
|
|
}
|
|
const pingTimer = setInterval(() => send('ping', ''), KEEPALIVE_INTERVAL_MS)
|
|
|
|
// Abort if the API provider doesn't produce any event within the timeout.
|
|
// Catches slow proxies, invalid keys on slow endpoints, etc.
|
|
const connectAbort = new AbortController()
|
|
let gotSdkEvent = false
|
|
const connectTimer = setTimeout(() => {
|
|
if (!gotSdkEvent) connectAbort.abort()
|
|
}, API_CONNECT_TIMEOUT_MS)
|
|
|
|
try {
|
|
const thinking = getAnthropicThinkingConfig(body)
|
|
const messageStream = client.messages.stream({
|
|
model: model || 'claude-sonnet-4-5-20250929',
|
|
max_tokens: 16384,
|
|
system: body.system,
|
|
messages: buildAnthropicMessages(body) as any,
|
|
...(body.effort ? { effort: body.effort } : {}),
|
|
...(thinking ? { thinking } : {}),
|
|
}, { signal: connectAbort.signal })
|
|
|
|
for await (const ev of messageStream) {
|
|
if (!gotSdkEvent) {
|
|
gotSdkEvent = true
|
|
clearTimeout(connectTimer)
|
|
}
|
|
if (ev.type === 'content_block_delta') {
|
|
if (ev.delta.type === 'text_delta') {
|
|
clearInterval(pingTimer)
|
|
send('text', ev.delta.text)
|
|
} else if (ev.delta.type === 'thinking_delta') {
|
|
send('thinking', ev.delta.thinking)
|
|
}
|
|
}
|
|
}
|
|
|
|
send('done', '')
|
|
} catch (error) {
|
|
clearTimeout(connectTimer)
|
|
const content = connectAbort.signal.aborted && !gotSdkEvent
|
|
? 'API connection timed out (30s). Check your API key and network configuration.'
|
|
: error instanceof Error ? error.message : 'Unknown error'
|
|
send('error', content)
|
|
} finally {
|
|
clearTimeout(connectTimer)
|
|
clearInterval(pingTimer)
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream)
|
|
}
|
|
|
|
/** 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 emittedText = false
|
|
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 (modelOverride?: string): Promise<string> => {
|
|
const q = query({
|
|
prompt,
|
|
options: {
|
|
systemPrompt: effectiveSystemPrompt,
|
|
...(modelOverride ? { model: modelOverride } : {}),
|
|
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}`
|
|
if (modelOverride && shouldRetryClaudeWithoutModel(errContent)) {
|
|
throw new Error(errContent)
|
|
}
|
|
throw new Error(errContent)
|
|
}
|
|
}
|
|
return ''
|
|
} finally {
|
|
q.close()
|
|
}
|
|
}
|
|
|
|
let resultText: string
|
|
try {
|
|
resultText = await runImageQuery(model)
|
|
} catch (error) {
|
|
const raw = error instanceof Error ? error.message : String(error)
|
|
if (model && shouldRetryClaudeWithoutModel(raw)) {
|
|
resultText = await runImageQuery(undefined)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
clearInterval(pingTimer)
|
|
if (resultText) {
|
|
emittedText = true
|
|
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 (modelOverride?: string) => {
|
|
const q = query({
|
|
prompt,
|
|
options: {
|
|
systemPrompt: effectiveSystemPrompt,
|
|
...(modelOverride ? { model: modelOverride } : {}),
|
|
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') {
|
|
emittedText = true
|
|
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}`
|
|
if (modelOverride && !emittedText && shouldRetryClaudeWithoutModel(content)) {
|
|
throw new Error(content)
|
|
}
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
q.close()
|
|
}
|
|
}
|
|
|
|
try {
|
|
await runQuery(model)
|
|
} catch (error) {
|
|
const raw = error instanceof Error ? error.message : String(error)
|
|
if (model && !emittedText && shouldRetryClaudeWithoutModel(raw)) {
|
|
await runQuery(undefined)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
async function promptOpenCodeWithThinking(
|
|
ocClient: any,
|
|
basePayload: Record<string, unknown>,
|
|
body: ChatBody,
|
|
): Promise<{ data: any; error: any }> {
|
|
const reasoning = buildOpenCodeReasoning(body)
|
|
if (!reasoning) {
|
|
return await ocClient.session.prompt(basePayload)
|
|
}
|
|
|
|
const enhanced = { ...basePayload, reasoning }
|
|
const firstTry = await ocClient.session.prompt(enhanced)
|
|
if (!firstTry.error) {
|
|
return firstTry
|
|
}
|
|
|
|
console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.')
|
|
return await ocClient.session.prompt(basePayload)
|
|
}
|
|
|
|
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 (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 { 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')
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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.' },
|
|
]
|
|
|
|
// Send prompt and await full response
|
|
const promptPayload: Record<string, unknown> = {
|
|
sessionID: session.id,
|
|
...(parsed ? { model: parsed } : {}),
|
|
parts,
|
|
}
|
|
|
|
const { data: result, error: promptError } = await promptOpenCodeWithThinking(
|
|
ocClient,
|
|
promptPayload,
|
|
body,
|
|
)
|
|
|
|
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 {
|
|
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
|
|
releaseOpencodeServer(ocServer)
|
|
clearInterval(pingTimer)
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream)
|
|
}
|