mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
- Path icons: use uniform scaling (preserve aspect ratio instead of squishing), fillRule 'evenodd' for compound paths with cutouts, transparent fill for stroke-only icons, strokeUniform to keep stroke width constant - Text centering: use actual Fabric rendered height (fontSize * lineHeight) for cross-axis centering instead of declared height - Hover cursor: use default arrow instead of move/crosshair on elements - Stream reliability: server sends keep-alive pings every 15s during API TTFT, forwards thinking_delta events, client resets timeout on ping/thinking chunks - Remove silent fallback from streamChat to generateCompletion (was causing double requests when server is unresponsive) - Add 3-minute timeout to generateCompletion - AI prompts: instruct model to preserve icon aspect ratios
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
|
|
|
interface ChatBody {
|
|
system: string
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
|
model?: string
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
})
|
|
|
|
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
|
|
|
|
/** 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')
|
|
const client = new Anthropic({ apiKey })
|
|
|
|
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)
|
|
try {
|
|
const messageStream = client.messages.stream({
|
|
model: model || 'claude-sonnet-4-5-20250929',
|
|
max_tokens: 16384,
|
|
system: body.system,
|
|
messages: body.messages,
|
|
})
|
|
|
|
for await (const ev of messageStream) {
|
|
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') {
|
|
clearInterval(pingTimer)
|
|
const data = JSON.stringify({ type: 'thinking', content: ev.delta.thinking })
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
}
|
|
}
|
|
}
|
|
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
|
|
)
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : 'Unknown error'
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', content: msg })}\n\n`),
|
|
)
|
|
} finally {
|
|
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)
|
|
|
|
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')
|
|
const prompt = lastUserMsg?.content ?? ''
|
|
|
|
// Remove CLAUDECODE env to allow running from within a CC terminal
|
|
const env = { ...process.env } as Record<string, string | undefined>
|
|
delete env.CLAUDECODE
|
|
|
|
const q = query({
|
|
prompt,
|
|
options: {
|
|
systemPrompt: body.system,
|
|
model: model || 'claude-sonnet-4-6',
|
|
maxTurns: 1,
|
|
includePartialMessages: true,
|
|
tools: [],
|
|
permissionMode: 'plan',
|
|
persistSession: false,
|
|
env,
|
|
},
|
|
})
|
|
|
|
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') {
|
|
clearInterval(pingTimer)
|
|
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') {
|
|
if (message.subtype !== 'success') {
|
|
const errors = 'errors' in message ? (message.errors as string[]) : []
|
|
const content = errors.join('; ') || `Query ended with: ${message.subtype}`
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\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)
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream)
|
|
}
|