diff --git a/server/api/ai/chat.ts b/server/api/ai/chat.ts
index 4df1c1d22..19576c828 100644
--- a/server/api/ai/chat.ts
+++ b/server/api/ai/chat.ts
@@ -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') {
diff --git a/src/components/editor/toolbar.tsx b/src/components/editor/toolbar.tsx
index 4ae43923e..dec85cdd1 100644
--- a/src/components/editor/toolbar.tsx
+++ b/src/components/editor/toolbar.tsx
@@ -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 (
diff --git a/src/components/panels/ai-chat-panel.tsx b/src/components/panels/ai-chat-panel.tsx
index c28067da9..ea5833fd1 100644
--- a/src/components/panels/ai-chat-panel.tsx
+++ b/src/components/panels/ai-chat-panel.tsx
@@ -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)
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index f898b133d..6aa9efa4c 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,5 +1,6 @@
import {
HeadContent,
+ Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
@@ -27,9 +28,19 @@ export const Route = createRootRoute({
},
],
}),
+ component: RootComponent,
+ notFoundComponent: () => (
+
+ ),
shellComponent: RootDocument,
})
+function RootComponent() {
+ return
+}
+
function RootDocument({ children }: { children: React.ReactNode }) {
return (
diff --git a/src/services/ai/ai-prompts.ts b/src/services/ai/ai-prompts.ts
index 5ae8b9ffd..6a3251fa5 100644
--- a/src/services/ai/ai-prompts.ts
+++ b/src/services/ai/ai-prompts.ts
@@ -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.
diff --git a/src/services/ai/ai-types.ts b/src/services/ai/ai-types.ts
index 0e6fe291e..d591efead 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' | 'done' | 'error'
+ type: 'text' | 'thinking' | 'done' | 'error'
content: string
}
diff --git a/src/utils/svg-parser.ts b/src/utils/svg-parser.ts
new file mode 100644
index 000000000..2e060569e
--- /dev/null
+++ b/src/utils/svg-parser.ts
@@ -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