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 afccf469d0
commit 87788ee97f
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)
}