From d939b1c88f640e16c3863ed68b963107d6a1c703 Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 20 Feb 2026 03:04:13 +0800 Subject: [PATCH] fix(canvas,ai): path icon rendering, text centering, and stream reliability - 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 --- server/api/ai/chat.ts | 27 ++++++++++++++++++ src/canvas/canvas-object-factory.ts | 17 +++++++++-- src/canvas/canvas-object-sync.ts | 21 +++++++++++--- src/canvas/use-canvas-sync.ts | 21 ++++++++++++-- src/services/ai/ai-prompts.ts | 8 +++--- src/services/ai/ai-service.ts | 44 ++++++++++++++++++++++++----- src/services/ai/ai-types.ts | 2 +- src/services/ai/design-generator.ts | 30 +------------------- 8 files changed, 121 insertions(+), 49 deletions(-) diff --git a/server/api/ai/chat.ts b/server/api/ai/chat.ts index 72739a86..efa91846 100644 --- a/server/api/ai/chat.ts +++ b/server/api/ai/chat.ts @@ -36,6 +36,9 @@ export default defineEventHandler(async (event) => { 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') @@ -44,6 +47,12 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str 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', @@ -55,8 +64,13 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str 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`)) } } } @@ -70,6 +84,7 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str encoder.encode(`data: ${JSON.stringify({ type: 'error', content: msg })}\n\n`), ) } finally { + clearInterval(pingTimer) controller.close() } }, @@ -83,6 +98,12 @@ 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') @@ -114,8 +135,13 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { 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') { @@ -138,6 +164,7 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), ) } finally { + clearInterval(pingTimer) controller.close() } }, diff --git a/src/canvas/canvas-object-factory.ts b/src/canvas/canvas-object-factory.ts index 6efa7655..54c6e13a 100644 --- a/src/canvas/canvas-object-factory.ts +++ b/src/canvas/canvas-object-factory.ts @@ -275,17 +275,30 @@ export function createFabricObject( case 'path': { const pw = sizeToNumber(node.width, 0) const ph = sizeToNumber(node.height, 0) + const hasExplicitFill = node.fill && node.fill.length > 0 + const hasStroke = !!node.stroke + // Stroke-only icons (e.g. Lucide-style) must not get a default fill. + // Use 'transparent' (not 'none' — Fabric.js ignores 'none' and falls back to black). + const pathFill = hasExplicitFill + ? resolveFill(node.fill, pw || 100, ph || 100) + : hasStroke + ? 'transparent' + : DEFAULT_FILL obj = new fabric.Path(node.d, { ...baseProps, - fill: resolveFill(node.fill, pw || 100, ph || 100), + fill: pathFill, stroke: resolveStrokeColor(node.stroke), strokeWidth: resolveStrokeWidth(node.stroke), + strokeUniform: true, + fillRule: 'evenodd', // Compound paths: inner sub-paths become transparent cutouts }) as FabricObjectWithPenId // Cache native dimensions before scaling (Path width/height is derived from d) ;(obj as any).__nativeWidth = obj.width ;(obj as any).__nativeHeight = obj.height if (pw > 0 && ph > 0 && obj.width && obj.height) { - obj.set({ scaleX: pw / obj.width, scaleY: ph / obj.height }) + // Uniform scale — preserve aspect ratio so icons don't get squished + const uniformScale = Math.min(pw / obj.width, ph / obj.height) + obj.set({ scaleX: uniformScale, scaleY: uniformScale }) } break } diff --git a/src/canvas/canvas-object-sync.ts b/src/canvas/canvas-object-sync.ts index fc26b856..7a7a441e 100644 --- a/src/canvas/canvas-object-sync.ts +++ b/src/canvas/canvas-object-sync.ts @@ -132,17 +132,30 @@ export function syncFabricObject( case 'path': { const w = sizeToNumber('width' in node ? node.width : undefined, 100) const h = sizeToNumber('height' in node ? node.height : undefined, 100) + const hasExplicitFill = node.type === 'path' && 'fill' in node && node.fill && node.fill.length > 0 + const hasStroke = 'stroke' in node && !!node.stroke + // For path nodes: stroke-only icons must not get a default fill + const fill = node.type === 'path' && !hasExplicitFill && hasStroke + ? 'transparent' + : resolveFill('fill' in node ? node.fill : undefined, w, h) obj.set({ - fill: resolveFill(node.fill, w, h), - stroke: resolveStrokeColor(node.stroke), - strokeWidth: resolveStrokeWidth(node.stroke), + fill, + stroke: resolveStrokeColor('stroke' in node ? node.stroke : undefined), + strokeWidth: resolveStrokeWidth('stroke' in node ? node.stroke : undefined), + ...(node.type === 'path' ? { strokeUniform: true, fillRule: 'evenodd' } : {}), }) // Use cached native dimensions (from path/points data) to compute correct // scale, even if obj.width was previously corrupted by scale baking. const nw = (obj as any).__nativeWidth || obj.width const nh = (obj as any).__nativeHeight || obj.height if (w > 0 && h > 0 && nw && nh) { - obj.set({ width: nw, height: nh, scaleX: w / nw, scaleY: h / nh }) + if (node.type === 'path') { + // Uniform scale — preserve aspect ratio so icons don't get squished + const uniformScale = Math.min(w / nw, h / nh) + obj.set({ width: nw, height: nh, scaleX: uniformScale, scaleY: uniformScale }) + } else { + obj.set({ width: nw, height: nh, scaleX: w / nw, scaleY: h / nh }) + } } break } diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 161fe4d9..2432f829 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -161,7 +161,8 @@ function getNodeHeight(node: PenNode, parentAvail?: number): number { } if (node.type === 'text') { const fontSize = node.fontSize ?? 16 - return fontSize * 1.4 + const lineHeight = ('lineHeight' in node ? node.lineHeight : undefined) ?? 1.2 + return fontSize * lineHeight } return 0 } @@ -258,9 +259,25 @@ function computeLayoutPositions( const childCross = isVertical ? size.w : size.h let crossPos = 0 + // For text nodes, use the actual Fabric-rendered height for cross-axis + // centering instead of the declared height. Fabric.js text height = + // fontSize * lineHeight, which is typically smaller than the AI-declared + // height, causing text to appear shifted upward when centered. + let effectiveChildCross = childCross + if (align === 'center' && child.type === 'text') { + const fontSize = child.fontSize ?? 16 + const lineHeight = ('lineHeight' in child ? child.lineHeight : undefined) ?? 1.2 + const visualH = fontSize * lineHeight + if (!isVertical && visualH < childCross) { + effectiveChildCross = visualH + } else if (isVertical && visualH < childCross) { + // vertical layout: cross axis is width, not applicable + } + } + switch (align) { case 'center': - crossPos = (crossAvail - childCross) / 2 + crossPos = (crossAvail - effectiveChildCross) / 2 break case 'end': crossPos = crossAvail - childCross diff --git a/src/services/ai/ai-prompts.ts b/src/services/ai/ai-prompts.ts index 6bb928d0..e49b0e67 100644 --- a/src/services/ai/ai-prompts.ts +++ b/src/services/ai/ai-prompts.ts @@ -4,7 +4,7 @@ PenNode types (the ONLY format you output for designs): - rectangle: Props: width, height, cornerRadius, fill, stroke, effects - ellipse: Props: width, height, fill, stroke, effects - text: Props: content (string), fontFamily, fontSize, fontWeight, fill, width, height, textAlign -- path: SVG icon/shape. Props: d (SVG path string), width, height, fill, stroke, effects +- path: SVG icon/shape. Props: d (SVG path string), width, height, fill, stroke, effects. IMPORTANT: width and height must match the natural aspect ratio of the SVG path — do NOT force 1:1 for non-square icons/logos - image: Raster image. Props: src (URL string), width, height, cornerRadius, effects All nodes share: id (string), type, name, x, y, rotation, opacity @@ -32,7 +32,7 @@ Card with image: { "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": 320, "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": 320, "height": 140, "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "width": 280, "height": 28, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "width": 280, "height": 20, "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] } ICONS & IMAGES: -- Icons: Use "path" nodes with Lucide-style SVG d attribute (24x24 viewBox). Use stroke for line icons, fill for solid icons. Size 16-24px. +- Icons: Use "path" nodes with SVG d attribute. Use stroke for line icons, fill for solid icons. Size 16-24px for UI icons. IMPORTANT: width and height must match the SVG path's natural aspect ratio — symmetric icons like arrows are square, but brand logos (Apple, Meta, etc.) are often taller than wide or vice versa. Never force all icons to 1:1. - Images: Use "image" nodes. src = "https://picsum.photos/{width}/{height}" for placeholders. Set explicit width/height. - You know many Lucide icon SVG paths — use them freely. Always give icon nodes descriptive names. ` @@ -123,7 +123,7 @@ DESIGN GUIDELINES: - Buttons: height 44-48px, cornerRadius 8-12 - Inputs: height 44px, light bg, subtle border - Consistent color palette -- Use path nodes for icons (SVG d path data, Lucide-style 24x24 viewBox). Size icons 16-24px in UI elements +- Use path nodes for icons (SVG d path data). Size icons 16-24px. Preserve the natural aspect ratio of the SVG path — do NOT force all icons to square - Use image nodes for photos/illustrations with picsum.photos placeholder URLs - Buttons, nav items, and list items should include icons when appropriate for better UX` @@ -172,7 +172,7 @@ SIZING: - All colors as fill arrays: [{ "type": "solid", "color": "#hex" }] ICONS & IMAGES: -- Use "path" nodes for icons: provide SVG d attribute, set width/height (16-24px for UI icons), use stroke for line icons or fill for solid icons +- Use "path" nodes for icons: provide SVG d attribute, set width/height (16-24px for UI icons), use stroke for line icons or fill for solid icons. Width and height MUST match the natural aspect ratio of the SVG path data — do not squeeze non-square logos into square dimensions - Use "image" nodes for photos/illustrations: set src to "https://picsum.photos/{width}/{height}" as placeholder, set explicit width/height - Include icons in buttons, nav items, list items, cards for professional polish - Reference the icon patterns in the examples section for common icons diff --git a/src/services/ai/ai-service.ts b/src/services/ai/ai-service.ts index eced557d..b5eb993e 100644 --- a/src/services/ai/ai-service.ts +++ b/src/services/ai/ai-service.ts @@ -100,11 +100,24 @@ export async function* streamChat( return } + // Keep-alive pings from server — reset timeout but don't yield + if (chunk.type === 'ping') { + clearNoTextTimeout() + noTextTimeout = setTimeout(() => { + if (!hasNonEmptyText) { + abortReason = 'no_text_timeout' + controller.abort() + } + }, noTextTimeoutMs) + continue + } + if (chunk.type === 'thinking' && !chunk.content) { continue } - if (chunk.type === 'text' && chunk.content.trim().length > 0) { + // Any non-empty content (text or thinking) counts as activity + if ((chunk.type === 'text' || chunk.type === 'thinking') && chunk.content.trim().length > 0) { hasNonEmptyText = true clearNoTextTimeout() } @@ -143,7 +156,7 @@ export async function* streamChat( clearNoTextTimeout() return } - if (chunk.type === 'text' && chunk.content.trim().length > 0) { + if ((chunk.type === 'text' || chunk.type === 'thinking') && chunk.content.trim().length > 0) { hasNonEmptyText = true clearNoTextTimeout() } @@ -194,16 +207,33 @@ export async function* streamChat( * Non-streaming completion for design/code generation. * Calls the server-side endpoint which reads ANTHROPIC_API_KEY from env. */ +const DEFAULT_GENERATE_TIMEOUT_MS = 180_000 + export async function generateCompletion( systemPrompt: string, userMessage: string, model?: string, ): Promise { - const response = await fetch('/api/ai/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ system: systemPrompt, message: userMessage, model }), - }) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_GENERATE_TIMEOUT_MS) + + let response: Response + try { + response = await fetch('/api/ai/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ system: systemPrompt, message: userMessage, model }), + signal: controller.signal, + }) + } catch (error) { + clearTimeout(timeout) + if (controller.signal.aborted) { + throw new Error('AI generation request timed out. Please retry.') + } + throw error + } finally { + clearTimeout(timeout) + } if (!response.ok) { throw new Error(`Server error: ${response.status}`) diff --git a/src/services/ai/ai-types.ts b/src/services/ai/ai-types.ts index d591efea..dcbcfc1f 100644 --- a/src/services/ai/ai-types.ts +++ b/src/services/ai/ai-types.ts @@ -22,6 +22,6 @@ export interface AICodeRequest { } export interface AIStreamChunk { - type: 'text' | 'thinking' | 'done' | 'error' + type: 'text' | 'thinking' | 'done' | 'error' | 'ping' content: string } diff --git a/src/services/ai/design-generator.ts b/src/services/ai/design-generator.ts index 218eb454..ee75f872 100644 --- a/src/services/ai/design-generator.ts +++ b/src/services/ai/design-generator.ts @@ -1,6 +1,6 @@ import type { PenNode } from '@/types/pen' import type { AIDesignRequest } from './ai-types' -import { streamChat, generateCompletion } from './ai-service' +import { streamChat } from './ai-service' import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts' import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store' import { useHistoryStore } from '@/stores/history-store' @@ -143,11 +143,6 @@ function scoreNodeSet(nodes: PenNode[]): number { return score } -function isTimeoutError(content: string): boolean { - const lower = content.toLowerCase() - return lower.includes('timed out') || lower.includes('thinking too long') -} - export async function generateDesign( request: AIDesignRequest, callbacks?: { @@ -232,18 +227,6 @@ export async function generateDesign( } if (streamError) { - if (isTimeoutError(streamError)) { - // Fallback path: one non-streaming generation attempt. - const fallbackResponse = await generateCompletion( - DESIGN_GENERATOR_PROMPT, - userMessage, - ) - const fallbackNodes = extractJsonFromResponse(fallbackResponse) - if (fallbackNodes && fallbackNodes.length > 0) { - return { nodes: fallbackNodes, rawResponse: fallbackResponse } - } - return { nodes: [], rawResponse: fallbackResponse } - } throw new Error(streamError) } @@ -296,17 +279,6 @@ export async function generateDesignModification( } if (streamError) { - if (isTimeoutError(streamError)) { - const fallbackResponse = await generateCompletion( - DESIGN_MODIFIER_PROMPT, - userMessage, - ) - const fallbackNodes = extractJsonFromResponse(fallbackResponse) - if (fallbackNodes && fallbackNodes.length > 0) { - return { nodes: fallbackNodes, rawResponse: fallbackResponse } - } - throw new Error('Failed to parse modified nodes from AI fallback response') - } throw new Error(streamError) }