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
This commit is contained in:
Fini 2026-02-20 03:04:13 +08:00
parent dd3744fc8d
commit d939b1c88f
8 changed files with 121 additions and 49 deletions

View file

@ -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()
}
},

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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<string> {
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}`)

View file

@ -22,6 +22,6 @@ export interface AICodeRequest {
}
export interface AIStreamChunk {
type: 'text' | 'thinking' | 'done' | 'error'
type: 'text' | 'thinking' | 'done' | 'error' | 'ping'
content: string
}

View file

@ -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)
}