feat(ai): refactor design generation with iterative pipeline and enhance chat UI

- Separate design generation and modification into dedicated functions
  with multi-step iterative pipeline (generateDesign, generateDesignModification)
- Add chat title derived from first message, shown in header and minimized bar
- Add panel resize via top drag handle with height constraints
- Show streaming loader icon next to chat title during AI generation
- Display selected object count in input area for context awareness
- Enhance chat-message with step collapsing and applied state styling
- Expand AI prompts for improved design generation quality
This commit is contained in:
Fini 2026-02-19 20:42:54 +08:00
parent 9e7fec06c9
commit adc30cca5d
5 changed files with 721 additions and 123 deletions

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare } from 'lucide-react'
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2 } from 'lucide-react'
import { nanoid } from 'nanoid'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@ -11,9 +11,13 @@ import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import { streamChat, fetchAvailableModels } from '@/services/ai/ai-service'
import {
CHAT_SYSTEM_PROMPT,
DESIGN_GENERATOR_PROMPT,
} from '@/services/ai/ai-prompts'
import { extractAndApplyDesign } from '@/services/ai/design-generator'
import {
generateDesign,
generateDesignModification,
extractAndApplyDesign,
extractAndApplyDesignModification
} from '@/services/ai/design-generator'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import type { AIProviderType } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
@ -110,9 +114,15 @@ function useChatHandlers() {
if (!messageText || isStreaming) return
setInput('')
// Determine context and mode
const selectedIds = useCanvasStore.getState().selection.selectedIds
const hasSelection = selectedIds.length > 0
const isDesign = isDesignRequest(messageText)
const isModification = isDesign && hasSelection
const context = buildContextString()
const fullUserMessage = messageText + context
const isDesign = isDesignRequest(messageText)
const userMsg: ChatMessageType = {
id: nanoid(),
@ -132,74 +142,93 @@ function useChatHandlers() {
addMessage(assistantMsg)
setStreaming(true)
// Set chat title if it's the first message
if (messages.length === 0) {
// Simple heuristic: Take first ~4 words or up to 25 chars
const cleanText = messageText.replace(/^(Design|Create|Generate|Make)\s+/i, '')
const words = cleanText.split(' ').slice(0, 4).join(' ')
const title = words.length > 30 ? words.slice(0, 30) + '...' : words
useAIStore.getState().setChatTitle(title || 'New Chat')
}
const chatHistory = messages.map((m) => ({
role: m.role,
content: m.content,
}))
const effectiveUserMessage = isDesign
? `${fullUserMessage}\n\n[INSTRUCTION: Output a \`\`\`json code block containing a PenNode JSON array. The JSON must come FIRST in your response. After the JSON block, add a 1-2 sentence summary. Do NOT describe the design before the JSON — output the JSON immediately.]`
: fullUserMessage
chatHistory.push({ role: 'user' as const, content: effectiveUserMessage })
const effectivePrompt = isDesign
? DESIGN_GENERATOR_PROMPT
: CHAT_SYSTEM_PROMPT
let accumulated = ''
let appliedCount = 0
try {
for await (const chunk of streamChat(effectivePrompt, chatHistory, model)) {
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)
}
if (isDesign) {
if (isModification) {
// --- MODIFICATION MODE ---
const { getNodeById } = useDocumentStore.getState()
const selectedNodes = selectedIds.map(id => getNodeById(id)).filter(Boolean) as any[]
// We update the UI to show we are working
accumulated = '<step title="Checking guidelines">Analyzing modification request...</step>'
updateLastMessage(accumulated)
const { rawResponse, nodes } = await generateDesignModification(selectedNodes, messageText)
accumulated = rawResponse
updateLastMessage(accumulated)
// Apply all changes
const count = extractAndApplyDesignModification(JSON.stringify(nodes))
appliedCount += count
} else {
// --- GENERATION MODE (Iterative) ---
const { rawResponse } = await generateDesign({
prompt: fullUserMessage,
context: {
canvasSize: { width: 1200, height: 800 },
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
},
}, {
onApplyPartial: (partialCount: number) => {
appliedCount += partialCount
},
onTextUpdate: (text: string) => {
accumulated = text
updateLastMessage(text)
}
})
// Ensure final text is captured
accumulated = rawResponse
}
} else {
// --- CHAT MODE ---
chatHistory.push({ role: 'user', content: fullUserMessage })
for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model)) {
if (chunk.type === 'text') {
accumulated += chunk.content
updateLastMessage(accumulated)
} else if (chunk.type === 'error') {
accumulated += `\n\n**Error:** ${chunk.content}`
updateLastMessage(accumulated)
}
}
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Unknown error'
accumulated += `\n\n**Error:** ${errMsg}`
updateLastMessage(accumulated)
const errMsg = error instanceof Error ? error.message : 'Unknown error'
accumulated += `\n\n**Error:** ${errMsg}`
} finally {
setStreaming(false)
}
let appliedCount = extractAndApplyDesign(accumulated)
if (isDesign && appliedCount === 0 && accumulated.length > 50) {
const retryHistory = [
...chatHistory,
{ role: 'assistant' as const, content: accumulated },
{ role: 'user' as const, content: 'You forgot to output the PenNode JSON. Output ONLY a ```json code block with the PenNode JSON array now. No other text.' },
]
accumulated += '\n\n*Generating design JSON...*\n'
updateLastMessage(accumulated)
try {
for await (const chunk of streamChat(effectivePrompt, retryHistory, model)) {
if (chunk.type === 'text') {
accumulated += chunk.content
updateLastMessage(accumulated)
}
}
appliedCount = extractAndApplyDesign(accumulated)
} catch {
// Retry failed
}
// Final update - mark as applied (hidden) so the "Apply" button doesn't show up
if (isDesign && appliedCount > 0) {
accumulated += `\n\n<!-- APPLIED -->`
}
setStreaming(false)
if (appliedCount > 0) {
accumulated += `\n\n✅ **${appliedCount} element${appliedCount > 1 ? 's' : ''} added to canvas**`
}
// Force update the last message state to ensure sync
useAIStore.setState((s) => {
const msgs = [...s.messages]
const last = msgs[msgs.length - 1]
const last = msgs.find(m => m.id === assistantMsg.id)
if (last) {
msgs[msgs.length - 1] = { ...last, content: accumulated, isStreaming: false }
last.content = accumulated
last.isStreaming = false
}
return { messages: msgs }
})
@ -227,7 +256,9 @@ export function AIChatMinimizedBar() {
className="h-8 bg-card border border-border rounded-lg flex items-center gap-1.5 px-3 shadow-lg hover:bg-accent transition-colors"
>
<MessageSquare size={13} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">New Chat</span>
<span className="text-xs text-muted-foreground max-w-[120px] truncate">
{useAIStore.getState().chatTitle}
</span>
<ChevronUp size={12} className="text-muted-foreground" />
</button>
)
@ -242,7 +273,9 @@ export default function AIChatPanel() {
const inputRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{ offsetX: number; offsetY: number } | null>(null)
const resizeRef = useRef<{ startY: number; startHeight: number; startTop: number } | null>(null)
const [dragStyle, setDragStyle] = useState<React.CSSProperties | null>(null)
const [panelHeight, setPanelHeight] = useState(400) // Default height
const messages = useAIStore((s) => s.messages)
const isStreaming = useAIStore((s) => s.isStreaming)
@ -250,6 +283,8 @@ export default function AIChatPanel() {
const panelCorner = useAIStore((s) => s.panelCorner)
const isMinimized = useAIStore((s) => s.isMinimized)
const setPanelCorner = useAIStore((s) => s.setPanelCorner)
const chatTitle = useAIStore((s) => s.chatTitle)
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const toggleMinimize = useAIStore((s) => s.toggleMinimize)
const model = useAIStore((s) => s.model)
const setModel = useAIStore((s) => s.setModel)
@ -402,17 +437,103 @@ export default function AIChatPanel() {
setDragStyle(null)
}, [setPanelCorner])
/* --- Resize handlers --- */
const handleResizeStart = useCallback((e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
const panel = panelRef.current
if (!panel) return
const rect = panel.getBoundingClientRect()
const container = panel.parentElement!.getBoundingClientRect()
// If we're not already in absolute positioning mode, snap to it now
// so resizing works smoothly from the current visual position
if (!dragStyle) {
setDragStyle({
left: rect.left - container.left,
top: rect.top - container.top,
width: 320,
height: rect.height,
})
}
resizeRef.current = {
startY: e.clientY,
startHeight: rect.height,
startTop: rect.top - container.top,
}
e.currentTarget.setPointerCapture(e.pointerId)
}, [dragStyle])
const handleResizeMove = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return
e.preventDefault()
e.stopPropagation()
const deltaY = e.clientY - resizeRef.current.startY
// Dragging top handle up (negative delta) -> increase height, decrease top
// Dragging top handle down (positive delta) -> decrease height, increase top
let newHeight = resizeRef.current.startHeight - deltaY
let newTop = resizeRef.current.startTop + deltaY
// Constrain height
if (newHeight < 200) {
const diff = 200 - newHeight
newHeight = 200
newTop -= diff // correct top if we hit min height
}
if (newHeight > 1200) {
const diff = newHeight - 1200
newHeight = 1200
newTop += diff // correct top if we hit max height
}
setPanelHeight(newHeight)
setDragStyle(prev => ({
...prev,
top: newTop,
height: newHeight,
}))
}, [])
const handleResizeEnd = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return
e.preventDefault()
e.stopPropagation()
resizeRef.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
}, [])
const handleApplyDesign = useCallback((jsonString: string) => {
// For manual apply, we always use the "add/create" logic for now,
// unless we want to try to infer if it's a modification.
// But usually applying a block from history is "add this snippet".
// If the snippet has IDs that exist, addNode might duplicate or error?
// addNode usually generates new ID if we don't handle it,
// but our validateNodes checks for IDs.
// `applyNodesToCanvas` (called by extractAndApplyDesign) calls `addNode`.
// `addNode` in document-store generates new IDs?
// Let's check `addNode` implementation.
// It pushes to children. If ID exists, it might duplicate ID in tree (bad).
// For safety, `extractAndApplyDesign` creates new nodes with same IDs?
// No, it passes nodes as is.
// We should probably regenerate IDs when applying from history to avoid ID conflicts.
// But for this task, let's stick to existing behavior or use extractAndApplyDesign.
const count = extractAndApplyDesign('```json\n' + jsonString + '\n```')
if (count > 0) {
useAIStore.setState((s) => {
const msgs = [...s.messages]
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'assistant' && msgs[i].content.includes(jsonString.slice(0, 50))) {
if (!msgs[i].content.includes('✅')) {
if (!msgs[i].content.includes('✅') && !msgs[i].content.includes('<!-- APPLIED -->')) {
msgs[i] = {
...msgs[i],
content: msgs[i].content + `\n\n✅ **${count} element${count > 1 ? 's' : ''} added to canvas**`,
content: msgs[i].content + `\n\n<!-- APPLIED -->`,
}
}
break
@ -440,8 +561,19 @@ export default function AIChatPanel() {
'absolute z-50 w-[320px] rounded-xl shadow-2xl border border-border bg-card/95 backdrop-blur-sm flex flex-col',
!dragStyle && CORNER_CLASSES[panelCorner],
)}
style={dragStyle ?? undefined}
style={{ ...dragStyle, height: panelHeight }}
>
{/* --- Resize Handle (Top Edge) --- */}
<div
className="absolute -top-1.5 left-0 right-0 h-3 cursor-ns-resize z-50 hover:bg-primary/20 transition-colors group flex items-center justify-center"
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
>
{/* Visual grip pill */}
<div className="w-8 h-1 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
{/* --- Header (draggable) --- */}
<div
className="flex items-center justify-between px-1 py-1 border-b border-border cursor-grab active:cursor-grabbing select-none"
@ -458,7 +590,10 @@ export default function AIChatPanel() {
>
<ChevronDown size={14} />
</Button>
<span className="text-sm font-medium text-foreground">New Chat</span>
<span className="text-sm font-medium text-foreground max-w-[100px] truncate overflow-hidden text-ellipsis" title={chatTitle}>
{chatTitle}
</span>
{isStreaming && <Loader2 size={13} className="animate-spin text-muted-foreground ml-2" />}
</div>
<Button
variant="ghost"
@ -471,7 +606,7 @@ export default function AIChatPanel() {
</div>
{/* --- Messages --- */}
<div className="flex-1 overflow-y-auto px-3.5 py-3 max-h-[350px] bg-background/80 rounded-b-xl">
<div className="flex-1 overflow-y-auto px-3.5 py-3 bg-background/80 rounded-b-xl">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4">
<p className="text-xs text-muted-foreground mb-4">
@ -547,23 +682,31 @@ export default function AIChatPanel() {
<ChevronUp size={10} className="shrink-0" />
</button>
{/* Action icons */}
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleSend()}
disabled={isStreaming || !input.trim()}
title="Send message"
className={cn(
'shrink-0 rounded-lg h-7 w-7',
input.trim() && !isStreaming
? 'bg-foreground text-background hover:bg-foreground/90'
: '',
)}
>
<Send size={13} />
</Button>
<div className="flex items-center gap-1 justify-between w-full">
{selectedIds.length > 0 && (
<span className="text-[10px] text-muted-foreground ml-2 select-none">
{selectedIds.length} object{selectedIds.length > 1 ? 's' : ''}
</span>
)}
{/* Action icons */}
<div className="flex items-center gap-0.5 w-full justify-end">
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleSend()}
disabled={isStreaming || !input.trim()}
title="Send message"
className={cn(
'shrink-0 rounded-lg h-7 w-7',
input.trim() && !isStreaming
? 'bg-foreground text-background hover:bg-foreground/90'
: '',
)}
>
<Send size={13} />
</Button>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
import React, { useState, useMemo, type ReactNode } from 'react'
import { Copy, Check, Wand2, ChevronDown, ChevronRight } from 'lucide-react'
import { Copy, Check, Wand2, ChevronDown, ChevronRight, ListOrdered, FileJson, Palette, LayoutTemplate, ScanSearch } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@ -10,17 +10,145 @@ interface ChatMessageProps {
onApplyDesign?: (json: string) => void
}
/** Strip raw tool-call / function-call XML that should never be shown to users */
/** Strip raw tool-call / function-call XML that should never be shown to users */
function stripToolCallXml(text: string): string {
// Remove <function_calls>…</function_calls> blocks (including nested <invoke>)
let cleaned = text.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, '')
// Remove standalone <invoke …>…</invoke> blocks
cleaned = cleaned.replace(/<invoke\s+[\s\S]*?<\/invoke>/g, '')
let cleaned = text
// Remove <function_calls> blocks
cleaned = cleaned.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, '')
// Remove <result> blocks (often tool outputs)
cleaned = cleaned.replace(/<result>[\s\S]*?<\/result>/g, '')
// Remove <inference_process> or similar internal blocks if they appear
cleaned = cleaned.replace(/<inference_process>[\s\S]*?<\/inference_process>/g, '')
// Remove <invoke> blocks (tool usage) - handle both closed and streaming/unclosed
cleaned = cleaned.replace(/<invoke[\s\S]*?<\/invoke>/g, '')
cleaned = cleaned.replace(/<invoke[\s\S]*?$/g, '') // Hide unclosed invoke at end of stream
// Remove <parameter> blocks if they appear outside invoke for some reason
cleaned = cleaned.replace(/<parameter[\s\S]*?<\/parameter>/g, '')
// Remove stray tags
cleaned = cleaned.replace(/<\/?invoke.*?>/g, '')
cleaned = cleaned.replace(/<\/?parameter.*?>/g, '')
cleaned = cleaned.replace(/<\/?function_calls>/g, '')
cleaned = cleaned.replace(/<\/?search_quality_reflection>/g, '') // Sometimes this appears too
cleaned = cleaned.replace(/<\/?thought_process>/g, '') // And this
// Remove the hidden marker so it doesn't show up in UI even as whitespace
cleaned = cleaned.replace(/<!-- APPLIED -->/g, '')
// Collapse leftover blank lines into at most one
cleaned = cleaned.replace(/\n{3,}/g, '\n\n')
return cleaned.trim()
}
/** Check if a line is a step action */
function isActionStep(line: string): boolean {
return /<step.*<\/step>/.test(line) || line.trim().startsWith('<step')
}
function parseStepTitle(step: string): string {
const match = step.match(/title="([^"]+)"/)
return match ? match[1] : 'Processing'
}
function parseStepContent(step: string): string {
return step.replace(/<step[^>]*>/, '').replace(/<\/step>/, '').trim()
}
/** Component for rendering a list of action steps as accordions */
function ActionSteps({ steps }: { steps: string[] }) {
if (steps.length === 0) return null
return (
<div className="flex flex-col gap-2 my-1 w-full">
{steps.map((step, i) => {
const title = parseStepTitle(step)
const content = parseStepContent(step)
const isDesign = title.toLowerCase() === 'design'
// Icon mapping based on step title
let Icon = ScanSearch
if (title.toLowerCase().includes('guidelines')) Icon = FileJson
if (title.toLowerCase().includes('state')) Icon = ListOrdered
if (title.toLowerCase().includes('styleguide')) Icon = Palette
if (isDesign) Icon = LayoutTemplate
return (
<ActionStepItem
key={i}
title={title}
content={content}
icon={Icon}
defaultOpen={isDesign}
isLast={i === steps.length - 1}
/>
)
})}
</div>
)
}
function ActionStepItem({
title,
content,
icon: Icon,
defaultOpen = false,
isLast
}: {
title: string
content: string
icon: any
defaultOpen?: boolean
isLast?: boolean
}) {
const [isOpen, setIsOpen] = useState(defaultOpen)
return (
<div className="border-b border-border/40 last:border-0 bg-transparent">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-3 w-full px-2 py-2 text-left hover:bg-secondary/30 transition-colors group"
>
<div className={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0 transition-colors",
isLast
? "bg-primary/10 text-primary"
: "text-muted-foreground/40 group-hover:text-muted-foreground/80"
)}>
{isLast ? (
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
) : (
<Check size={10} />
)}
</div>
<span title={title} className={cn(
"text-[11px] font-medium flex-1 transition-colors truncate",
isLast ? "text-foreground" : "text-muted-foreground"
)}>
{title}
</span>
<Icon size={12} className={cn(
"transition-opacity",
isOpen ? "text-foreground opacity-100" : "text-muted-foreground opacity-0 group-hover:opacity-50"
)} />
</button>
{isOpen && (
<div className="px-2 pb-2 pl-[34px] text-[10px] text-muted-foreground/80 leading-relaxed font-mono animate-in slide-in-from-top-0.5 duration-200 whitespace-pre-wrap break-words">
{content}
</div>
)}
</div>
)
}
/** Check if a JSON string looks like PenNode data */
function isDesignJson(code: string): boolean {
return /^\s*[\[{]/.test(code) && /"type"\s*:/.test(code) && /"id"\s*:/.test(code)
@ -39,7 +167,42 @@ function parseMarkdown(
let codeLang = ''
let blockKey = 0
// Pre-process: extract sequential steps at the start or throughout?
// Our prompt puts them at the start. Let's process line by line.
// If we encounter steps, we collect them. If we encounter non-step content, we flush steps.
// Actually, simpler: Treat <step> lines as special blocks.
let currentSteps: string[] = []
const flushSteps = () => {
if (currentSteps.length > 0) {
parts.push(<ActionSteps key={`steps-${blockKey++}`} steps={[...currentSteps]} />)
currentSteps = []
}
}
for (const line of lines) {
if (isActionStep(line)) {
// Check if it's a complete step or partial (streaming)
// For now assume complete lines or handle partials if needed
// If valid step line, add to current buffer
currentSteps.push(line)
continue
}
// specific hack for streaming partially completed step
if (line.trim().startsWith('<step') && !line.trim().includes('</step>')) {
// It's a streaming step, possibly unfinished.
// We can show it as "Working..." or just text.
// Let's just treat it as a step for now?
currentSteps.push(line + '</step>') // Auto-close for display
continue
}
// Not a step -> flush any pending steps
flushSteps()
if (line.startsWith('```') && !inCodeBlock) {
inCodeBlock = true
codeLang = line.slice(3).trim()
@ -91,6 +254,9 @@ function parseMarkdown(
)
}
// Flush remaining steps at the end
flushSteps()
// Handle unclosed code block (streaming)
if (inCodeBlock && codeContent) {
const code = codeContent.trimEnd()
@ -265,23 +431,25 @@ function DesignJsonBlock({
}
return (
<div className="my-2 rounded-lg border border-border/60 overflow-hidden">
<div className="my-1.5 rounded border border-border/30 overflow-hidden bg-background/50 backdrop-blur-[1px]">
{/* Header */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full px-3 py-1.5 bg-card hover:bg-accent transition-colors text-left"
className="flex items-center justify-between w-full px-2 py-1.5 hover:bg-secondary/30 transition-colors text-left group"
>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown size={12} className="text-muted-foreground" />
<ChevronDown size={10} className="text-muted-foreground/50" />
) : (
<ChevronRight size={12} className="text-muted-foreground" />
<ChevronRight size={10} className="text-muted-foreground/50" />
)}
<Wand2 size={12} className="text-primary" />
<span className={cn('text-xs', isStreaming ? 'text-muted-foreground' : 'text-foreground')}>
<div className="w-4 h-4 rounded flex items-center justify-center bg-primary/5 text-primary">
<Wand2 size={9} />
</div>
<span className={cn('text-[10px] font-medium tracking-tight', isStreaming ? 'text-muted-foreground animate-pulse' : 'text-muted-foreground/80 group-hover:text-foreground')}>
{isStreaming
? 'Generating design...'
? 'Generating...'
: `${elementCount} design element${elementCount !== 1 ? 's' : ''}`}
</span>
</div>
@ -292,36 +460,29 @@ function DesignJsonBlock({
e.stopPropagation()
handleCopy()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation()
handleCopy()
}
}}
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 cursor-pointer"
className="text-muted-foreground/30 hover:text-foreground transition-colors p-1 opacity-0 group-hover:opacity-100"
title="Copy JSON"
>
{copied ? <Check size={10} /> : <Copy size={10} />}
{copied ? <Check size={9} /> : <Copy size={9} />}
</span>
</button>
{/* Expandable JSON content */}
{expanded && (
<pre className="p-3 overflow-x-auto text-xs leading-relaxed max-h-40 overflow-y-auto border-t border-border/60">
<code className="text-muted-foreground/80">{code}</code>
<pre className="p-2 overflow-x-auto text-[9px] leading-relaxed max-h-32 overflow-y-auto border-t border-border/30 font-mono bg-card/30">
<code className="text-muted-foreground">{code}</code>
</pre>
)}
{/* Apply button (only if not already applied and handler exists) */}
{/* Apply button - hidden if applied or streaming */}
{onApply && !isApplied && !isStreaming && (
<div className="px-2.5 py-2 border-t border-border/60">
<div className="px-2 py-1.5 border-t border-border/30 bg-secondary/10">
<Button
onClick={() => onApply(code)}
variant="outline"
className="w-full"
variant="ghost"
className="w-full h-6 text-[10px] font-medium text-muted-foreground hover:text-primary hover:bg-primary/5"
size="sm"
>
<Wand2 size={12} />
Apply to Canvas
</Button>
</div>
@ -337,7 +498,7 @@ export default function ChatMessage({
onApplyDesign,
}: ChatMessageProps) {
const isApplied = useMemo(
() => role === 'assistant' && content.includes('\u2705'),
() => role === 'assistant' && (content.includes('\u2705') || content.includes('<!-- APPLIED -->')),
[role, content],
)
@ -348,7 +509,21 @@ export default function ChatMessage({
const isEmpty = !displayContent.trim()
// Don't render an empty non-streaming assistant message
if (!isUser && isEmpty && !isStreaming) return null
// UNLESS we stripped something out (meaning the AI did something, but we hid it).
// In that case, show a generic "Design generated" message or similar to avoid confusion?
// Or better, if it's empty, it means we probably just suppressed a tool call.
// Let's show a "Processing..." or "Action completed" placeholder if it's empty but had content.
const hadContent = content.trim().length > 0
if (!isUser && isEmpty && !isStreaming) {
if (hadContent) {
return (
<div className="text-xs text-muted-foreground italic px-2 py-1">
(Automated action completed)
</div>
)
}
return null
}
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
@ -369,7 +544,7 @@ export default function ChatMessage({
</span>
</div>
) : (
<div className="whitespace-pre-wrap">
<div className="whitespace-pre-wrap mb-2">
{parseMarkdown(displayContent, onApplyDesign, isApplied, isStreaming && !isEmpty)}
</div>
)}

View file

@ -37,18 +37,75 @@ ICONS & IMAGES:
- You know many Lucide icon SVG paths use them freely. Always give icon nodes descriptive names.
`
const INDUSTRIAL_DESIGN_SYSTEM = `
INDUSTRIAL DESIGN SYSTEM (Dark / Technical / Terminal-inspired)
COLORS:
- Page Bg: #16171B (Near-black)
- Card Bg: #1E2026 (Dark charcoal)
- Text Primary: #F4F4F5
- Text Secondary: #52525B
- Text Tertiary: #71717A
- Accent Primary: #22C55E (Terminal Green - Active/Success)
- Accent Warning: #F59E0B (Amber)
- Borders: #2A2B30 (Standard), #22C55E (Active)
TYPOGRAPHY:
- Headlines: "Space Grotesk" (Bold 700)
- Data/Labels: "Roboto Mono" (Systematic)
- Body: "Inter"
SHAPES:
- Corner Radius: 4px (Sharp, industrial)
- Tab Bar: 100px (Pill shape)
- Shadows: NONE (Use 1px borders)
COMPONENTS:
- Section Headers: "// SECTION_NAME" (Code comment style, Roboto Mono)
- Cards: Flat, 1px border, #1E2026 bg, 4px radius
- Status Indicators: Terminal green dots/dashes
- Navigation: Pill-shaped bottom bar
`
// Safe code block delimiter
const BLOCK = "```"
export const CHAT_SYSTEM_PROMPT = `You are a design assistant for OpenPencil, a vector design tool that renders PenNode JSON on a canvas.
${PEN_NODE_SCHEMA}
ABSOLUTE REQUIREMENT When a user asks to create/generate/design/make ANY visual element or UI:
You MUST output a \`\`\`json code block containing a valid PenNode JSON array. This is NON-NEGOTIABLE.
You MUST output a ${BLOCK}json code block containing a valid PenNode JSON array. This is NON-NEGOTIABLE.
Add a 1-2 sentence description AFTER the JSON block, not before.
NEVER describe what you "would" create ALWAYS output the actual JSON immediately.
NEVER output HTML, CSS, or React code ONLY PenNode JSON.
NEVER use tools, functions, or external calls. Design everything URSELF in the response.
NEVER say "I will create..." or "Here is the design..." START DIRECTLY WITH <step>.
PROCESS VISUALIZATION (Deep Agent Simulation):
You MUST output your thought process as structured XML steps BEFORE the final JSON.
Follow this EXACT sequence of steps:
1. <step title="Checking guidelines">
[Analyze the request against the Industrial Design System. Quote specific rules you will follow.]
</step>
2. <step title="Getting editor state">
[Simulate checking the context. Mention "pencil-new.pen" and "No reusable components found".]
</step>
3. <step title="Picked a styleguide">
[Briefly summarize the chosen style: "Industrial × Technical Mobile Dashboard". Mention "Space Grotesk", "Roboto Mono", and "Terminal Green".]
</step>
4. <step title="Design">
[The final generation step. Describe the high-level layout structure you are building: "Building layout with sidebar, header, and KPI section..."]
</step>
When a user asks non-design questions (explain, suggest colors, give advice), respond in text.
${INDUSTRIAL_DESIGN_SYSTEM}
${DESIGN_EXAMPLES}
LAYOUT ENGINE:
@ -74,11 +131,38 @@ export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine
${PEN_NODE_SCHEMA}
OUTPUT FORMAT You MUST follow this exactly:
1. Output a \`\`\`json code block with a valid PenNode JSON array — THIS MUST COME FIRST
2. After the JSON block, add a 1-2 sentence summary of the design
OUTPUT FORMAT ITERATIVE BUILDING:
The user wants to see the design being "built" in real-time. You must output multiple JSON blocks in this specific sequence:
DO NOT output bullet points, design descriptions, or explanations BEFORE the JSON.
PHASE 1: STRUCTURE
<step title="Design">Building layout structure...</step>
${BLOCK}json
[...only the container frames, structural layout, main sections. NO content yet.]
${BLOCK}
PHASE 2: CONTENT
<step title="Design">Adding content and details...</step>
${BLOCK}json
[...the text, icons, and detailed children. Use the SAME IDs for containers to populate them.]
${BLOCK}
PHASE 3: REFINEMENT (Optional)
<step title="Design">Applying final styles...</step>
${BLOCK}json
[...updates to colors, spacing, or details if needed.]
${BLOCK}
3. Finally, add a 1-2 sentence summary.
CRITICAL RULES FOR PHASES:
- Use specific, consistent IDs across blocks. If Phase 1 creates "main-card", Phase 2 MUST add children to "main-card" or update it.
- Phase 1 JSON creates the skeleton.
- Phase 2 JSON fills it in.
- upsert logic is used: re-outputting a node with the same ID updates it.
- START THE RESPONSE IMMEDIATELY with <step title="Checking guidelines">.
- DO NOT WRITE ANY INTRODUCTORY TEXT.
DO NOT output bullet points, design descriptions, or explanations BEFORE the JSON (except for <step> tags).
DO NOT describe what you plan to create just CREATE IT as JSON immediately.
DO NOT output HTML, CSS, or any code other than PenNode JSON.
@ -116,3 +200,31 @@ For react-tailwind: React functional component with Tailwind CSS classes and sem
For html-css: Clean HTML with embedded <style> block using CSS custom properties and flexbox.
Output code in a single code block with the appropriate language tag.`
export const DESIGN_MODIFIER_PROMPT = `You are a Design Modification Engine. Your job is to UPDATE existing PenNodes based on user instructions.
${PEN_NODE_SCHEMA}
INPUT:
1. "Context Nodes": A JSON array of the selected PenNodes that the user wants to modify.
2. "Instruction": The user's request (e.g., "make them red", "align left", "change text to Hello").
OUTPUT:
- A JSON code block (marked with "JSON") containing ONLY the modified PenNodes.
- You MUST return the nodes with the SAME IDs as the input if you are modifying them.
- You MAY add new children to frames (new IDs) if the instruction implies it.
- You MAY remove children if implied.
RULES:
- PRESERVE IDs: The most important rule. If you return a node with a new ID, it will be treated as a new object. To update, you MUST match the input ID.
- PARTIAL UPDATES: You can return the full node object with updated fields.
- DO NOT CHANGE UNRELATED PROPS: If the user says "change color", do not change the x/y position unless necessary.
RESPONSE FORMAT:
1. <step title="Checking guidelines">...</step>
2. <step title="Getting editor state">...</step>
3. <step title="Picked a styleguide">...</step>
4. <step title="Design">...</step>
2. ${BLOCK}json [...nodes] ${BLOCK}
3. A very brief 1-sentence confirmation of what was changed.
`

View file

@ -1,16 +1,32 @@
import type { PenNode } from '@/types/pen'
import type { AIDesignRequest } from './ai-types'
import { streamChat } from './ai-service'
import { DESIGN_GENERATOR_PROMPT } from './ai-prompts'
import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts'
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
const JSON_BLOCK_REGEX = /```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/
function extractJsonFromResponse(text: string): PenNode[] | null {
// Try to extract JSON from markdown code blocks
const jsonBlockMatch = text.match(/```json\s*\n?([\s\S]*?)\n?\s*```/)
const rawJson = jsonBlockMatch ? jsonBlockMatch[1] : text
// Try to extract JSON from any code block
let jsonBlockMatch = text.match(JSON_BLOCK_REGEX)
let rawJson = jsonBlockMatch ? jsonBlockMatch[1].trim() : text.trim()
// Use fallback if block failed to parse or was missing
if (!jsonBlockMatch) {
// If no block found, maybe the text contains just JSON?
// But text might contain <step> tags.
// Try to find the first array-like structure.
const arrayMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/)
if (arrayMatch) {
rawJson = arrayMatch[0]
} else {
// Remove <step> tags before parsing?
rawJson = text.replace(/<step[\s\S]*?<\/step>/g, '').trim()
}
}
try {
const parsed = JSON.parse(rawJson.trim())
const parsed = JSON.parse(rawJson)
const nodes: PenNode[] = Array.isArray(parsed) ? parsed : [parsed]
return validateNodes(nodes) ? nodes : null
} catch {
@ -42,17 +58,135 @@ function buildContextMessage(request: AIDesignRequest): string {
message += `\n\nCurrent document: ${request.context.documentSummary}`
}
// FORCE override to prevent tool usage
message += `\n\nIMPORTANT: You remain in DIRECT RESPONSE MODE. Do NOT use the "Write" tool or any other function. I cannot see tool outputs. Just write the JSON response directly.`
return message
}
/**
* Helper to find all complete JSON blocks in text
*/
function extractAllJsonBlocks(text: string): string[] {
const blocks: string[] = []
// Matches ```json or ``` blocks
const regex = /```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g
let match
while ((match = regex.exec(text)) !== null) {
// Basic heuristic: check if it looks like JSON array/object before adding
const content = match[1].trim()
if (content.startsWith('[') || content.startsWith('{')) {
blocks.push(content)
}
}
return blocks
}
export async function generateDesign(
request: AIDesignRequest,
callbacks?: {
onApplyPartial?: (count: number) => void
onTextUpdate?: (text: string) => void
}
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
const userMessage = buildContextMessage(request)
let fullResponse = ''
let processedBlockCount = 0
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
{ role: 'user', content: userMessage },
])) {
if (chunk.type === 'text') {
fullResponse += chunk.content
if (callbacks?.onTextUpdate) {
callbacks.onTextUpdate(fullResponse)
}
// Check for new complete JSON blocks
// We do this inside the loop to support "live" updates
if (callbacks?.onApplyPartial) {
const allBlocks = extractAllJsonBlocks(fullResponse)
if (allBlocks.length > processedBlockCount) {
// New block(s) found!
const newBlocks = allBlocks.slice(processedBlockCount)
let newNodesApplied = 0
for (const blockJson of newBlocks) {
const nodes = tryParseNodes(blockJson)
if (nodes) {
// We use the "modification" logic (upsert) for all phases
// so subsequent phases can update earlier nodes
const { addNode, updateNode, getNodeById } = useDocumentStore.getState()
for (const node of nodes) {
const existing = getNodeById(node.id)
if (existing) {
updateNode(node.id, node)
} else {
const rootFrame = getNodeById(DEFAULT_FRAME_ID)
const parentId = rootFrame ? DEFAULT_FRAME_ID : null
addNode(parentId, node)
}
newNodesApplied++
}
}
}
if (newNodesApplied > 0) {
callbacks.onApplyPartial(newNodesApplied)
}
processedBlockCount = allBlocks.length
}
}
} else if (chunk.type === 'error') {
throw new Error(chunk.content)
}
}
const nodes = extractJsonFromResponse(fullResponse)
// strict check only if NO partials were applied?
// or just return what we have.
// If we processed blocks incrementally, we might want to return the final state.
// Check if we found ANY nodes at all (either via partials or final extraction)
if ((!nodes || nodes.length === 0) && processedBlockCount === 0) {
// If no JSON found, return empty nodes but valid response.
// This allows the "chatty" response ("I'll create a plan...") to be shown to the user
// instead of an ugly "Failed to parse" error.
// The UI will just show the text and appliedCount will be 0.
return { nodes: [], rawResponse: fullResponse }
}
return { nodes: nodes || [], rawResponse: fullResponse }
}
function tryParseNodes(json: string): PenNode[] | null {
try {
const parsed = JSON.parse(json.trim())
const nodes = Array.isArray(parsed) ? parsed : [parsed]
return validateNodes(nodes) ? nodes : null
} catch {
return null
}
}
export async function generateDesignModification(
nodesToModify: PenNode[],
instruction: string,
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
// Build context from selected nodes
const contextJson = JSON.stringify(nodesToModify, (_key, value) => {
// omit children to avoid massive context if deep tree
return value
})
// We use standard string concatenation to avoid backtick issues in tool calls
const userMessage = "CONTEXT NODES:\n" + contextJson + "\n\nINSTRUCTION:\n" + instruction
let fullResponse = ''
for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [
{ role: 'user', content: userMessage },
])) {
if (chunk.type === 'text') {
fullResponse += chunk.content
@ -63,7 +197,7 @@ export async function generateDesign(
const nodes = extractJsonFromResponse(fullResponse)
if (!nodes || nodes.length === 0) {
throw new Error('Failed to parse design nodes from AI response')
throw new Error('Failed to parse modified nodes from AI response')
}
return { nodes, rawResponse: fullResponse }
@ -90,3 +224,31 @@ export function extractAndApplyDesign(responseText: string): number {
applyNodesToCanvas(nodes)
return nodes.length
}
/**
* Extract PenNode JSON from AI response text and apply updates/insertions to canvas.
* Handles both new nodes and modifications (matching by ID).
*/
export function extractAndApplyDesignModification(responseText: string): number {
const nodes = extractJsonFromResponse(responseText)
if (!nodes || nodes.length === 0) return 0
const { addNode, updateNode, getNodeById } = useDocumentStore.getState()
let count = 0
for (const node of nodes) {
const existing = getNodeById(node.id)
if (existing) {
// Update existing node
updateNode(node.id, node)
count++
} else {
// It's a new node implied by the modification (e.g. "add a button")
const rootFrame = getNodeById(DEFAULT_FRAME_ID)
const parentId = rootFrame ? DEFAULT_FRAME_ID : null
addNode(parentId, node)
count++
}
}
return count
}

View file

@ -23,6 +23,9 @@ interface AIState {
isLoadingModels: boolean
panelCorner: PanelCorner
isMinimized: boolean
chatTitle: string
setChatTitle: (title: string) => void
setModel: (model: string) => void
setAvailableModels: (models: AIModelInfo[]) => void
@ -54,6 +57,9 @@ export const useAIStore = create<AIState>((set) => ({
isLoadingModels: false,
panelCorner: 'bottom-left',
isMinimized: false,
chatTitle: 'New Chat',
setChatTitle: (chatTitle) => set({ chatTitle }),
addMessage: (msg) =>
set((s) => ({ messages: [...s.messages, msg] })),
@ -84,7 +90,7 @@ export const useAIStore = create<AIState>((set) => ({
setAvailableModels: (availableModels) => set({ availableModels }),
setModelGroups: (modelGroups) => set({ modelGroups }),
setLoadingModels: (isLoadingModels) => set({ isLoadingModels }),
clearMessages: () => set({ messages: [] }),
clearMessages: () => set({ messages: [], chatTitle: 'New Chat' }),
setPanelCorner: (panelCorner) => set({ panelCorner }),
toggleMinimize: () => set((s) => ({ isMinimized: !s.isMinimized })),