openpencil/server/api/ai/chat.ts
Kayshen Xu ffbd2be112 V0.1.1 (#17)
* chore(electron): update mac build scripts for improved artifact handling

- Modified the `electron:build:mac-arm64` script to rename the generated YAML file for better clarity.
- Adjusted the `electron:build:mac-both` script to run builds sequentially without file renaming logic, ensuring consistent output.

* chore(electron): enable notarization for macOS builds and update build workflow secrets

- Added notarization support in `electron-builder.yml` for enhanced security.
- Updated GitHub Actions workflow to include necessary Apple credentials for notarization.

* chore(electron): add additional secrets for macOS notarization in build workflow

- Included CSC_LINK and CSC_KEY_PASSWORD in the GitHub Actions workflow to support code signing for macOS builds.

* feat(types): add ImageFitMode type and objectFit to ImageNode

Support fill/fit/crop/tile image scaling modes, matching Figma's
image fill behavior. Default is 'fill' (cover) for backward compat.

* feat(canvas): render images per fill mode with native crop

Add computeImageTransform helper supporting fill/fit/crop/tile modes.
Fill/crop uses FabricImage native cropX/cropY instead of clipPath to
avoid conflict with parent frame clipping. Tile mode creates a Rect
with Pattern fill. Detect mode changes via __needsRecreation flag for
object recreation when switching between tile and non-tile modes.

* feat(panels): add image fit mode dropdown to property panel

New ImageSection component with Fill/Fit/Crop/Tile dropdown for
image nodes. Wired into PropertyPanel between icon and appearance
sections.

* feat(figma): preserve image scale mode from Figma import

Map Figma imageScaleMode (FIT/FILL/TILE) to objectFit property on
imported ImageNodes so fill mode is preserved across import.

* fix(canvas): fix zoom-to-fit bounds inflated by clipped children

computeDocBounds was recursing into frame children, inflating the
bounding box beyond visible frame bounds. Now only recurses into
groups. Also use double-RAF in Figma import for reliable timing.

* feat(figma): implement Figma clipboard paste functionality

- Added a new hook, useFigmaPaste, to handle pasting Figma clipboard data into the canvas.
- Integrated clipboard data extraction and processing to convert Figma nodes into PenNodes.
- Enhanced keyboard shortcuts to attempt reading Figma data from the system clipboard as a fallback.
- Introduced utility functions for decoding and processing Figma clipboard HTML data.
- Updated editor layout to utilize the new Figma paste functionality.

* feat(figma): implement Figma clipboard support for pasting nodes

- Added a new hook, `useFigmaPaste`, to handle Figma clipboard data extraction and processing.
- Integrated Figma clipboard support into the editor layout and keyboard shortcuts for seamless pasting.
- Updated README to reflect changes in file format from `.pen` to `.op`.
- Refactored AI service methods to route to appropriate provider SDK based on the `provider` field, enhancing flexibility in AI interactions.

* fix(figma): preserve imported node order and disable openpencil auto layout

Prevent imported/generated nodes from being prepended in auto-layout containers, which could reverse visual order during progressive insertion.

Hide the unfinished OpenPencil auto-layout path from the import dialog to avoid selecting a mode that is not ready yet.

* fix(ai): enforce explicit provider and model routing

Pass selected provider and model through design generation, orchestration, sub-agent, and validation flows.

Disable provider/model fallback and remove model retry-without-selection behavior so requests fail fast instead of silently routing to Claude.

* chore(package): bump version to 0.1.1

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-02 22:26:09 +08:00

550 lines
20 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?: 'anthropic' | 'openai' | 'opencode'
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 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') {
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)
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 } = 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')
}
/** 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)
}
/** 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)
}