feat(chat): enhance chat streaming with 'thinking' state and improve SVG handling in toolbar

- Add support for 'thinking' state in chat message streaming to indicate processing.
- Update toolbar to parse SVG files into editable nodes, allowing for better integration of SVG graphics.
- Refactor image handling to accommodate both SVG and raster images, ensuring proper placement and scaling on the canvas.
- Improve error handling and user feedback during file uploads.
This commit is contained in:
Kayshen-X 2026-02-19 12:52:30 +08:00
parent 1950f8f618
commit a4e0934aa8
7 changed files with 486 additions and 46 deletions

View file

@ -53,12 +53,14 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str
})
for await (const ev of messageStream) {
if (
ev.type === 'content_block_delta' &&
ev.delta.type === 'text_delta'
) {
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
if (ev.type === 'content_block_delta') {
if (ev.delta.type === 'text_delta') {
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') {
const data = JSON.stringify({ type: 'thinking', content: '' })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
}
}
@ -113,12 +115,14 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
for await (const message of q) {
if (message.type === 'stream_event') {
const ev = message.event
if (
ev.type === 'content_block_delta' &&
ev.delta.type === 'text_delta'
) {
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
if (ev.type === 'content_block_delta') {
if (ev.delta.type === 'text_delta') {
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') {
const data = JSON.stringify({ type: 'thinking', content: '' })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
}
} else if (message.type === 'result') {
if (message.subtype !== 'success') {

View file

@ -14,6 +14,7 @@ import {
import ToolButton from './tool-button'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, generateId } from '@/stores/document-store'
import { parseSvgToNodes } from '@/utils/svg-parser'
import { useHistoryStore } from '@/stores/history-store'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
@ -66,43 +67,71 @@ export default function Toolbar() {
// Reset input so the same file can be re-selected
e.target.value = ''
const reader = new FileReader()
reader.onload = () => {
const dataUrl = reader.result as string
const img = new Image()
img.onload = () => {
const isSvg = file.type === 'image/svg+xml'
if (isSvg) {
// SVG → parse into editable path/shape nodes
const reader = new FileReader()
reader.onload = () => {
const svgText = reader.result as string
const nodes = parseSvgToNodes(svgText)
if (nodes.length === 0) return
const { viewport, fabricCanvas } = useCanvasStore.getState()
// Place image at center of current viewport
const canvasEl = fabricCanvas?.getElement()
const canvasW = canvasEl?.clientWidth ?? 800
const canvasH = canvasEl?.clientHeight ?? 600
const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom
const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom
// Scale down large images to fit reasonably on canvas
let w = img.naturalWidth
let h = img.naturalHeight
const maxDim = 400
if (w > maxDim || h > maxDim) {
const scale = maxDim / Math.max(w, h)
w = Math.round(w * scale)
h = Math.round(h * scale)
for (const node of nodes) {
const w = ('width' in node ? (typeof node.width === 'number' ? node.width : 100) : 100)
const h = ('height' in node ? (typeof node.height === 'number' ? node.height : 100) : 100)
node.x = centerX - w / 2
node.y = centerY - h / 2
node.name = file.name.replace(/\.[^.]+$/, '')
useDocumentStore.getState().addNode(null, node)
}
useDocumentStore.getState().addNode(null, {
id: generateId(),
type: 'image',
name: file.name.replace(/\.[^.]+$/, ''),
src: dataUrl,
x: centerX - w / 2,
y: centerY - h / 2,
width: w,
height: h,
})
}
img.src = dataUrl
reader.readAsText(file)
} else {
// Raster image → ImageNode with data URL
const reader = new FileReader()
reader.onload = () => {
const dataUrl = reader.result as string
const img = new Image()
img.onload = () => {
const { viewport, fabricCanvas } = useCanvasStore.getState()
const canvasEl = fabricCanvas?.getElement()
const canvasW = canvasEl?.clientWidth ?? 800
const canvasH = canvasEl?.clientHeight ?? 600
const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom
const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom
let w = img.naturalWidth
let h = img.naturalHeight
const maxDim = 400
if (w > maxDim || h > maxDim) {
const scale = maxDim / Math.max(w, h)
w = Math.round(w * scale)
h = Math.round(h * scale)
}
useDocumentStore.getState().addNode(null, {
id: generateId(),
type: 'image',
name: file.name.replace(/\.[^.]+$/, ''),
src: dataUrl,
x: centerX - w / 2,
y: centerY - h / 2,
width: w,
height: h,
})
}
img.src = dataUrl
}
reader.readAsDataURL(file)
}
reader.readAsDataURL(file)
}, [])
return (

View file

@ -152,6 +152,8 @@ function useChatHandlers() {
if (chunk.type === 'text') {
accumulated += chunk.content
updateLastMessage(accumulated)
} else if (chunk.type === 'thinking') {
// Model is in extended thinking phase — SSE heartbeat, no display update needed
} else if (chunk.type === 'error') {
accumulated += `\n\n**Error:** ${chunk.content}`
updateLastMessage(accumulated)

View file

@ -1,5 +1,6 @@
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
@ -27,9 +28,19 @@ export const Route = createRootRoute({
},
],
}),
component: RootComponent,
notFoundComponent: () => (
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
<p>Page not found</p>
</div>
),
shellComponent: RootDocument,
})
function RootComponent() {
return <Outlet />
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">

View file

@ -4,6 +4,8 @@ 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
- image: Raster image. Props: src (URL string), width, height, cornerRadius, effects
All nodes share: id (string), type, name, x, y, rotation, opacity
@ -23,11 +25,16 @@ RULES:
const DESIGN_EXAMPLES = `
EXAMPLES:
Button:
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 160, "height": 44, "cornerRadius": 8, "layout": "horizontal", "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-text", "type": "text", "name": "Label", "content": "Click Me", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
Button with icon:
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 44, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowIcon", "d": "M5 12h14M12 5l7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
Card:
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 200, "cornerRadius": 12, "layout": "vertical", "padding": 20, "gap": 12, "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-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" }] }] }
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.
- 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.
`
export const CHAT_SYSTEM_PROMPT = `You are a design assistant for OpenPencil, a vector design tool that renders PenNode JSON on a canvas.
@ -58,7 +65,10 @@ DESIGN GUIDELINES:
- Text: titles 22-28px bold, body 14-16px, captions 12px
- Buttons: height 44-48px, cornerRadius 8-12
- Inputs: height 44px, light bg, subtle border
- Consistent color palette`
- Consistent color palette
- Use path nodes for icons (SVG d path data, Lucide-style 24x24 viewBox). Size icons 16-24px in UI elements
- 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`
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON.
@ -88,7 +98,13 @@ SIZING:
- Use unique descriptive IDs
- All colors as fill arrays: [{ "type": "solid", "color": "#hex" }]
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette.`
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 "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
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette, purposeful iconography.`
export const CODE_GENERATOR_PROMPT = `You are a code generation engine for OpenPencil. Convert PenNode design descriptions into clean, production-ready code.

View file

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

378
src/utils/svg-parser.ts Normal file
View file

@ -0,0 +1,378 @@
import type { PenNode } from '@/types/pen'
import type { PenFill, PenStroke } from '@/types/styles'
import { generateId } from '@/stores/document-store'
/** Inherited style context passed from parent SVG/g elements */
interface StyleCtx {
fill: string | null
stroke: string | null
strokeWidth: number
}
/**
* Parse an SVG string into editable PenNode array.
* Handles fill/stroke inheritance, path coordinate scaling, and viewBox transforms.
*/
export function parseSvgToNodes(
svgText: string,
maxDim = 400,
): PenNode[] {
const parser = new DOMParser()
const doc = parser.parseFromString(svgText, 'image/svg+xml')
const svg = doc.querySelector('svg')
if (!svg) return []
// Resolve source dimensions from viewBox or width/height
const vb = svg.getAttribute('viewBox')?.split(/[\s,]+/).map(Number)
const svgW = parseFloat(svg.getAttribute('width') ?? '') || vb?.[2] || 100
const svgH = parseFloat(svg.getAttribute('height') ?? '') || vb?.[3] || 100
// Compute scale to fit within maxDim
const scale =
svgW > maxDim || svgH > maxDim ? maxDim / Math.max(svgW, svgH) : 1
// Build inherited style context from <svg> attributes
const rootCtx: StyleCtx = {
fill: svg.getAttribute('fill'),
stroke: svg.getAttribute('stroke'),
strokeWidth: parseFloat(svg.getAttribute('stroke-width') ?? '') || 1,
}
const nodes = parseChildren(svg, scale, rootCtx)
if (nodes.length === 0) return []
if (nodes.length === 1) return nodes
// Wrap multiple elements in a frame
return [
{
id: generateId(),
type: 'frame',
name: 'SVG',
width: Math.round(svgW * scale),
height: Math.round(svgH * scale),
layout: 'none' as const,
children: nodes,
},
]
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
const SKIP_TAGS = new Set([
'defs', 'style', 'title', 'desc', 'metadata',
'clippath', 'mask', 'filter', 'lineargradient', 'radialgradient',
'symbol', 'marker', 'pattern',
])
function parseChildren(
parent: Element,
scale: number,
ctx: StyleCtx,
): PenNode[] {
const nodes: PenNode[] = []
for (const el of parent.children) {
const node = parseElement(el, scale, ctx)
if (node) nodes.push(node)
}
return nodes
}
function parseElement(
el: Element,
scale: number,
parentCtx: StyleCtx,
): PenNode | null {
const tag = el.tagName.toLowerCase()
if (SKIP_TAGS.has(tag)) return null
// Merge this element's style context with parent
const ctx = mergeStyleCtx(parentCtx, el)
// Handle <g> by recursing with inherited styles
if (tag === 'g') {
const children = parseChildren(el, scale, ctx)
if (children.length === 0) return null
if (children.length === 1) return children[0]
return {
id: generateId(),
type: 'frame',
name: el.getAttribute('id') ?? 'Group',
layout: 'none' as const,
children,
}
}
// Resolve fill & stroke from element + inherited context
const fill = resolveFill(el, ctx)
const stroke = resolveStroke(el, ctx, scale)
const opacity = parseFloat(getAttr(el, 'opacity') ?? '1')
const base = {
id: generateId(),
opacity: opacity !== 1 ? opacity : undefined,
}
switch (tag) {
case 'path': {
const d = el.getAttribute('d')
if (!d) return null
const scaledD = scaleSvgPath(d, scale)
const bbox = getPathBBox(scaledD)
return {
...base,
type: 'path' as const,
name: el.getAttribute('id') ?? 'Path',
d: scaledD,
x: bbox.x,
y: bbox.y,
width: Math.round(bbox.w),
height: Math.round(bbox.h),
fill,
stroke,
}
}
case 'rect': {
const x = num(el, 'x') * scale
const y = num(el, 'y') * scale
const w = num(el, 'width') * scale
const h = num(el, 'height') * scale
const rx = num(el, 'rx') * scale
return {
...base,
type: 'rectangle' as const,
name: el.getAttribute('id') ?? 'Rectangle',
x, y,
width: Math.round(w),
height: Math.round(h),
cornerRadius: rx || undefined,
fill,
stroke,
}
}
case 'circle': {
const cx = num(el, 'cx') * scale
const cy = num(el, 'cy') * scale
const r = num(el, 'r') * scale
return {
...base,
type: 'ellipse' as const,
name: el.getAttribute('id') ?? 'Circle',
x: cx - r, y: cy - r,
width: Math.round(r * 2),
height: Math.round(r * 2),
fill,
stroke,
}
}
case 'ellipse': {
const cx = num(el, 'cx') * scale
const cy = num(el, 'cy') * scale
const rx = num(el, 'rx') * scale
const ry = num(el, 'ry') * scale
return {
...base,
type: 'ellipse' as const,
name: el.getAttribute('id') ?? 'Ellipse',
x: cx - rx, y: cy - ry,
width: Math.round(rx * 2),
height: Math.round(ry * 2),
fill,
stroke,
}
}
case 'line': {
return {
...base,
type: 'line' as const,
name: el.getAttribute('id') ?? 'Line',
x: num(el, 'x1') * scale,
y: num(el, 'y1') * scale,
x2: num(el, 'x2') * scale,
y2: num(el, 'y2') * scale,
stroke: stroke ?? { thickness: 1, fill: [{ type: 'solid', color: '#000000' }] },
}
}
case 'polygon':
case 'polyline': {
const pts = el.getAttribute('points')
if (!pts) return null
const scaledD = scaleSvgPath(pointsToD(pts, tag === 'polygon'), scale)
const bbox = getPathBBox(scaledD)
return {
...base,
type: 'path' as const,
name: el.getAttribute('id') ?? (tag === 'polygon' ? 'Polygon' : 'Polyline'),
d: scaledD,
x: bbox.x,
y: bbox.y,
width: Math.round(bbox.w),
height: Math.round(bbox.h),
fill: tag === 'polygon' ? fill : noFill(),
stroke,
}
}
default:
return null
}
}
// ---------------------------------------------------------------------------
// Style resolution with inheritance
// ---------------------------------------------------------------------------
/** Read an attribute from the element, checking inline `style` first */
function getAttr(el: Element, name: string): string | null {
// Check inline style first (higher priority)
const style = el.getAttribute('style')
if (style) {
const m = style.match(new RegExp(`${name}\\s*:\\s*([^;]+)`))
if (m) return m[1].trim()
}
return el.getAttribute(name)
}
/** Build style context by merging parent context with this element's attributes */
function mergeStyleCtx(parent: StyleCtx, el: Element): StyleCtx {
return {
fill: getAttr(el, 'fill') ?? parent.fill,
stroke: getAttr(el, 'stroke') ?? parent.stroke,
strokeWidth: parseFloat(getAttr(el, 'stroke-width') ?? '') || parent.strokeWidth,
}
}
/** Normalize a color string: resolve currentColor, inherit → black */
function normalizeColor(raw: string): string {
if (raw === 'currentColor' || raw === 'inherit') return '#000000'
return raw
}
/** Explicit "no fill" — transparent so canvas DEFAULT_FILL won't apply */
function noFill(): PenFill[] {
return [{ type: 'solid', color: 'transparent' }]
}
/** Resolve fill from element attribute + inherited context → PenFill[] */
function resolveFill(el: Element, ctx: StyleCtx): PenFill[] | undefined {
const raw = getAttr(el, 'fill') ?? ctx.fill
// Explicit "none" → transparent fill
if (raw === 'none') return noFill()
// url(#gradient) references — not supported, use black fallback
if (raw && raw.startsWith('url(')) return [{ type: 'solid', color: '#000000' }]
// Has a color
if (raw) return [{ type: 'solid', color: normalizeColor(raw) }]
// SVG spec default: black fill
return [{ type: 'solid', color: '#000000' }]
}
/** Resolve stroke from element attribute + inherited context → PenStroke */
function resolveStroke(el: Element, ctx: StyleCtx, scale: number): PenStroke | undefined {
const raw = getAttr(el, 'stroke') ?? ctx.stroke
if (!raw || raw === 'none') return undefined
if (raw.startsWith('url(')) return undefined
const width = (parseFloat(getAttr(el, 'stroke-width') ?? '') || ctx.strokeWidth) * scale
return {
thickness: width,
fill: [{ type: 'solid', color: normalizeColor(raw) }],
}
}
// ---------------------------------------------------------------------------
// SVG path data scaling
// ---------------------------------------------------------------------------
/**
* Scale all coordinates in an SVG path `d` string by a factor.
* Handles M/L/C/S/Q/T/H/V/A commands (both absolute and relative).
* For arc (A) commands, only rx/ry/x/y are scaled flags and rotation are preserved.
*/
function scaleSvgPath(d: string, scale: number): string {
if (scale === 1) return d
// Tokenize into commands and numbers
const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g)
if (!tokens) return d
let result = ''
let cmd = ''
let paramIdx = 0
for (const tok of tokens) {
if (/[a-zA-Z]/.test(tok)) {
cmd = tok
paramIdx = 0
result += tok
continue
}
const n = parseFloat(tok)
const upper = cmd.toUpperCase()
if (upper === 'A') {
// Arc: rx ry x-rotation large-arc-flag sweep-flag x y (7 params per arc)
const pos = paramIdx % 7
// Scale rx(0), ry(1), x(5), y(6); keep rotation(2), flags(3,4) unchanged
const shouldScale = pos === 0 || pos === 1 || pos === 5 || pos === 6
result += ' ' + (shouldScale ? n * scale : n)
} else {
// All other commands: every param is a coordinate → scale it
result += ' ' + n * scale
}
paramIdx++
}
return result.trim()
}
// ---------------------------------------------------------------------------
// Path bounding box (uses browser SVG engine for accuracy)
// ---------------------------------------------------------------------------
/** Compute the bounding box of an SVG path d string via the browser's SVG DOM */
function getPathBBox(d: string): { x: number; y: number; w: number; h: number } {
try {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.appendChild(path)
svg.style.position = 'absolute'
svg.style.width = '0'
svg.style.height = '0'
svg.style.overflow = 'hidden'
document.body.appendChild(svg)
const bbox = path.getBBox()
document.body.removeChild(svg)
return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height }
} catch {
return { x: 0, y: 0, w: 100, h: 100 }
}
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
function num(el: Element, attr: string): number {
return parseFloat(getAttr(el, attr) ?? '0') || 0
}
function pointsToD(points: string, close: boolean): string {
const nums = points.trim().split(/[\s,]+/).map(Number)
if (nums.length < 2) return ''
let d = `M${nums[0]} ${nums[1]}`
for (let i = 2; i < nums.length - 1; i += 2) {
d += `L${nums[i]} ${nums[i + 1]}`
}
if (close) d += 'Z'
return d
}