mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
9e7fec06c9
commit
adc30cca5d
5 changed files with 721 additions and 123 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
Loading…
Reference in a new issue