mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
dd3744fc8d
commit
d939b1c88f
8 changed files with 121 additions and 49 deletions
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@ export interface AICodeRequest {
|
|||
}
|
||||
|
||||
export interface AIStreamChunk {
|
||||
type: 'text' | 'thinking' | 'done' | 'error'
|
||||
type: 'text' | 'thinking' | 'done' | 'error' | 'ping'
|
||||
content: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue