feat(ai): integrate AI chat and design generation

Add floating AI assistant panel with streaming chat, design keyword
detection, and automatic PenNode JSON extraction to canvas. Server
endpoints use Anthropic SDK (with API key) or Claude Agent SDK (local
OAuth) as fallback. Panel supports minimize, drag-to-snap, and
quick action presets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kayshen-X 2026-02-18 21:49:41 +08:00
parent b425defdca
commit 6a7a0d0019
9 changed files with 1422 additions and 0 deletions

149
server/api/ai/chat.ts Normal file
View file

@ -0,0 +1,149 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
interface ChatBody {
system: string
messages: Array<{ role: 'user' | 'assistant'; content: string }>
}
/**
* Streaming chat endpoint.
* Tries ANTHROPIC_API_KEY first (via Anthropic SDK);
* falls back to local Claude Code (via Agent SDK, uses OAuth login).
*/
export default defineEventHandler(async (event) => {
const body = await readBody<ChatBody>(event)
if (!body?.messages || !body?.system) {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing required fields: system, messages' }
}
setResponseHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const apiKey = process.env.ANTHROPIC_API_KEY
if (apiKey) {
try {
return await streamViaAnthropicSDK(apiKey, body)
} catch {
// SDK not installed or failed — fall back to Agent SDK
}
}
return streamViaAgentSDK(body)
})
/** Stream via Anthropic SDK (when API key is available) */
async function streamViaAnthropicSDK(apiKey: string, body: ChatBody) {
// @ts-expect-error — optional dependency, only used when ANTHROPIC_API_KEY is set
const { default: Anthropic } = await import('@anthropic-ai/sdk')
const client = new Anthropic({ apiKey })
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
try {
const messageStream = client.messages.stream({
model: 'claude-sonnet-4-5-20250929',
max_tokens: 16384,
system: body.system,
messages: body.messages,
})
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`))
}
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
)
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error'
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content: msg })}\n\n`),
)
} finally {
controller.close()
}
},
})
return new Response(stream)
}
/** Stream via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
function streamViaAgentSDK(body: ChatBody) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
try {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
// Build prompt from the last user message
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
const prompt = lastUserMsg?.content ?? ''
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const q = query({
prompt,
options: {
systemPrompt: body.system,
model: 'claude-sonnet-4-6',
maxTurns: 1,
includePartialMessages: true,
tools: [],
permissionMode: 'plan',
persistSession: false,
env,
},
})
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`))
}
} else if (message.type === 'result') {
if (message.subtype !== 'success') {
const errors = 'errors' in message ? (message.errors as string[]) : []
const content = errors.join('; ') || `Query ended with: ${message.subtype}`
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
}
}
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
)
} catch (error) {
const content = error instanceof Error ? error.message : 'Unknown error'
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
} finally {
controller.close()
}
},
})
return new Response(stream)
}

90
server/api/ai/generate.ts Normal file
View file

@ -0,0 +1,90 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
interface GenerateBody {
system: string
message: string
}
/**
* Non-streaming AI generation endpoint.
* Tries ANTHROPIC_API_KEY first (via Anthropic SDK);
* falls back to local Claude Code (via Agent SDK, uses OAuth login).
*/
export default defineEventHandler(async (event) => {
const body = await readBody<GenerateBody>(event)
if (!body?.message || !body?.system) {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing required fields: system, message' }
}
const apiKey = process.env.ANTHROPIC_API_KEY
if (apiKey) {
try {
return await generateViaAnthropicSDK(apiKey, body)
} catch {
// SDK not installed or failed — fall back to Agent SDK
}
}
return generateViaAgentSDK(body)
})
/** Generate via Anthropic SDK */
async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody) {
try {
// @ts-expect-error — optional dependency, only used when ANTHROPIC_API_KEY is set
const { default: Anthropic } = await import('@anthropic-ai/sdk')
const client = new Anthropic({ apiKey })
const response = await client.messages.create({
model: 'claude-sonnet-4-5-20250929',
max_tokens: 4096,
system: body.system,
messages: [{ role: 'user', content: body.message }],
})
const textBlock = response.content.find((b: { type: string }) => b.type === 'text')
return { text: textBlock?.text ?? '' }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { error: message }
}
}
/** Generate via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
async function generateViaAgentSDK(body: GenerateBody): Promise<{ text?: string; error?: string }> {
try {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const q = query({
prompt: body.message,
options: {
systemPrompt: body.system,
model: 'claude-sonnet-4-6',
maxTurns: 1,
tools: [],
permissionMode: 'plan',
persistSession: false,
env,
},
})
for await (const message of q) {
if (message.type === 'result') {
if (message.subtype === 'success') {
return { text: message.result }
}
const errors = 'errors' in message ? (message.errors as string[]) : []
return { error: errors.join('; ') || `Query ended with: ${message.subtype}` }
}
}
return { error: 'No result received from Claude Agent SDK' }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { error: message }
}
}

View file

@ -0,0 +1,462 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Trash2, Minus, Maximize2, Sparkles } from 'lucide-react'
import { nanoid } from 'nanoid'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useAIStore } from '@/stores/ai-store'
import type { PanelCorner } from '@/stores/ai-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { streamChat } 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 type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import ChatMessage from './chat-message'
const QUICK_ACTIONS = [
{
label: '生成登录页',
prompt: 'Design a modern mobile login screen with email input, password input, login button, and social login options',
},
{
label: '生成卡片',
prompt: 'Create a product card with an image placeholder, title, price, and buy button',
},
{
label: '生成导航栏',
prompt: 'Design a mobile app bottom navigation bar with 5 tabs: Home, Search, Add, Messages, Profile',
},
{
label: '配色建议',
prompt: 'Suggest a modern color palette for a pet care app',
},
]
const CORNER_CLASSES: Record<PanelCorner, string> = {
'top-left': 'top-3 left-3',
'top-right': 'top-3 right-3',
'bottom-left': 'bottom-3 left-3',
'bottom-right': 'bottom-3 right-3',
}
/** Detect if a message is a design generation request */
function isDesignRequest(text: string): boolean {
const lower = text.toLowerCase()
const designKeywords = [
'生成', '设计', '创建', '画', '做一个', '来一个', '弄一个',
'generate', 'create', 'design', 'make', 'build', 'draw',
'add a', 'add an', 'place a', 'insert',
'界面', '页面', 'screen', 'page', 'layout', 'component',
'按钮', '卡片', '导航', '表单', '输入框', '列表',
'button', 'card', 'nav', 'form', 'input', 'list',
'header', 'footer', 'sidebar', 'modal', 'dialog',
'login', 'signup', 'dashboard', 'profile',
]
return designKeywords.some((kw) => lower.includes(kw))
}
function buildContextString(): string {
const selectedIds = useCanvasStore.getState().selection.selectedIds
const flatNodes = useDocumentStore.getState().getFlatNodes()
const parts: string[] = []
if (flatNodes.length > 0) {
const summary = flatNodes
.slice(0, 20)
.map((n) => `${n.type}:${n.name ?? n.id}`)
.join(', ')
parts.push(`Document has ${flatNodes.length} nodes: ${summary}`)
}
if (selectedIds.length > 0) {
const selectedNodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter(Boolean)
const selectedSummary = selectedNodes
.map((n) => `${n!.type}:${n!.name ?? n!.id}`)
.join(', ')
parts.push(`Selected: ${selectedSummary}`)
}
return parts.length > 0 ? `\n\n[Canvas context: ${parts.join('. ')}]` : ''
}
/** Shared chat logic hook */
function useChatHandlers() {
const [input, setInput] = useState('')
const messages = useAIStore((s) => s.messages)
const isStreaming = useAIStore((s) => s.isStreaming)
const addMessage = useAIStore((s) => s.addMessage)
const updateLastMessage = useAIStore((s) => s.updateLastMessage)
const setStreaming = useAIStore((s) => s.setStreaming)
const handleSend = useCallback(
async (text?: string) => {
const messageText = text ?? input.trim()
if (!messageText || isStreaming) return
setInput('')
const context = buildContextString()
const fullUserMessage = messageText + context
const isDesign = isDesignRequest(messageText)
const userMsg: ChatMessageType = {
id: nanoid(),
role: 'user',
content: messageText,
timestamp: Date.now(),
}
addMessage(userMsg)
const assistantMsg: ChatMessageType = {
id: nanoid(),
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
}
addMessage(assistantMsg)
setStreaming(true)
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 = ''
try {
for await (const chunk of streamChat(effectivePrompt, chatHistory)) {
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)
}
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)) {
if (chunk.type === 'text') {
accumulated += chunk.content
updateLastMessage(accumulated)
}
}
appliedCount = extractAndApplyDesign(accumulated)
} catch {
// Retry failed
}
}
setStreaming(false)
if (appliedCount > 0) {
accumulated += `\n\n✅ **${appliedCount} element${appliedCount > 1 ? 's' : ''} added to canvas**`
}
useAIStore.setState((s) => {
const msgs = [...s.messages]
const last = msgs[msgs.length - 1]
if (last) {
msgs[msgs.length - 1] = { ...last, content: accumulated, isStreaming: false }
}
return { messages: msgs }
})
},
[input, isStreaming, messages, addMessage, updateLastMessage, setStreaming],
)
return { input, setInput, handleSend, isStreaming }
}
/**
* Minimized AI bar a compact clickable pill.
* Parent is responsible for placing it in the layout.
*/
export function AIChatMinimizedBar() {
const isMinimized = useAIStore((s) => s.isMinimized)
const toggleMinimize = useAIStore((s) => s.toggleMinimize)
if (!isMinimized) return null
return (
<button
type="button"
onClick={toggleMinimize}
className="h-7 bg-card border border-border rounded-lg flex items-center gap-1.5 px-3 shadow-lg hover:bg-accent transition-colors"
>
<Sparkles size={12} className="text-purple-400" />
<span className="text-xs text-muted-foreground">AI Assistant</span>
<Maximize2 size={12} className="text-muted-foreground" />
</button>
)
}
/**
* Expanded AI chat panel floating, draggable.
* Only renders when NOT minimized.
*/
export default function AIChatPanel() {
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{ offsetX: number; offsetY: number } | null>(null)
const [dragStyle, setDragStyle] = useState<React.CSSProperties | null>(null)
const messages = useAIStore((s) => s.messages)
const isStreaming = useAIStore((s) => s.isStreaming)
const clearMessages = useAIStore((s) => s.clearMessages)
const panelCorner = useAIStore((s) => s.panelCorner)
const isMinimized = useAIStore((s) => s.isMinimized)
const setPanelCorner = useAIStore((s) => s.setPanelCorner)
const toggleMinimize = useAIStore((s) => s.toggleMinimize)
const { input, setInput, handleSend } = useChatHandlers()
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Auto-expand when streaming starts while minimized
useEffect(() => {
if (isStreaming && isMinimized) {
toggleMinimize()
}
}, [isStreaming, isMinimized, toggleMinimize])
/* --- Drag-to-snap handlers --- */
const handleDragStart = useCallback((e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest('button, input, textarea')) return
const panel = panelRef.current
if (!panel) return
const panelRect = panel.getBoundingClientRect()
dragRef.current = {
offsetX: e.clientX - panelRect.left,
offsetY: e.clientY - panelRect.top,
}
e.currentTarget.setPointerCapture(e.pointerId)
const container = panel.parentElement!
const containerRect = container.getBoundingClientRect()
setDragStyle({
left: panelRect.left - containerRect.left,
top: panelRect.top - containerRect.top,
right: 'auto',
bottom: 'auto',
})
}, [])
const handleDragMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return
const panel = panelRef.current
if (!panel) return
const container = panel.parentElement!
const containerRect = container.getBoundingClientRect()
setDragStyle({
left: e.clientX - containerRect.left - dragRef.current.offsetX,
top: e.clientY - containerRect.top - dragRef.current.offsetY,
right: 'auto',
bottom: 'auto',
})
}, [])
const handleDragEnd = useCallback(() => {
if (!dragRef.current) return
const panel = panelRef.current
if (!panel) return
const container = panel.parentElement!
const containerRect = container.getBoundingClientRect()
const panelRect = panel.getBoundingClientRect()
const centerX = panelRect.left + panelRect.width / 2 - containerRect.left
const centerY = panelRect.top + panelRect.height / 2 - containerRect.top
const isLeft = centerX < containerRect.width / 2
const isTop = centerY < containerRect.height / 2
const corner: PanelCorner = isLeft
? isTop ? 'top-left' : 'bottom-left'
: isTop ? 'top-right' : 'bottom-right'
setPanelCorner(corner)
dragRef.current = null
setDragStyle(null)
}, [setPanelCorner])
const handleApplyDesign = useCallback((jsonString: string) => {
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('✅')) {
msgs[i] = {
...msgs[i],
content: msgs[i].content + `\n\n✅ **${count} element${count > 1 ? 's' : ''} added to canvas**`,
}
}
break
}
}
return { messages: msgs }
})
}
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Don't render when minimized — the minimized bar is rendered by parent
if (isMinimized) return null
return (
<div
ref={panelRef}
className={cn(
'absolute z-50 w-[360px] rounded-xl shadow-2xl border border-border bg-card/95 backdrop-blur-sm flex flex-col overflow-hidden',
!dragStyle && CORNER_CLASSES[panelCorner],
)}
style={dragStyle ?? undefined}
>
{/* --- Header (draggable) --- */}
<div
className="flex items-center justify-between px-3 py-2 border-b border-border cursor-grab active:cursor-grabbing select-none"
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
>
<div className="flex items-center gap-2">
<Sparkles size={14} className="text-purple-400" />
<span className="text-xs font-medium text-foreground">
AI Assistant
</span>
</div>
<div className="flex items-center gap-1">
{messages.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={clearMessages}
title="Clear chat"
>
<Trash2 size={12} />
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={toggleMinimize}
title="Minimize"
>
<Minus size={12} />
</Button>
</div>
</div>
{/* --- Messages --- */}
<div className="overflow-y-auto p-3 max-h-[350px]">
{messages.length === 0 ? (
<div className="text-center mt-4">
<div className="text-2xl mb-2">🎨</div>
<p className="text-sm text-foreground mb-1 font-medium">
Design with AI
</p>
<p className="text-xs text-muted-foreground mb-4">
Describe any UI and it appears on canvas
</p>
<div className="flex flex-wrap gap-2 justify-center">
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
type="button"
onClick={() => handleSend(action.prompt)}
className="text-xs px-2.5 py-1.5 rounded-full bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
{action.label}
</button>
))}
</div>
</div>
) : (
messages.map((msg) => (
<ChatMessage
key={msg.id}
role={msg.role}
content={msg.content}
isStreaming={msg.isStreaming && isStreaming}
onApplyDesign={handleApplyDesign}
/>
))
)}
<div ref={messagesEndRef} />
</div>
{/* --- Input --- */}
<div className="p-2 border-t border-border">
<div className="flex items-end gap-1.5 bg-background/50 rounded-lg border border-input focus-within:border-ring transition-colors">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isStreaming ? 'Generating...' : 'Ask AI anything...'}
disabled={isStreaming}
rows={1}
className="flex-1 bg-transparent text-sm text-foreground placeholder-muted-foreground px-3 py-2 resize-none outline-none max-h-24 min-h-[36px]"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleSend()}
disabled={isStreaming || !input.trim()}
title="Send message"
className="shrink-0 m-1"
>
<Send size={14} />
</Button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,337 @@
import { useState, useMemo, type ReactNode } from 'react'
import { Copy, Check, Wand2, ChevronDown, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface ChatMessageProps {
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
onApplyDesign?: (json: string) => void
}
/** 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)
}
function parseMarkdown(
text: string,
onApplyDesign?: (json: string) => void,
isApplied?: boolean,
): ReactNode[] {
const parts: ReactNode[] = []
const lines = text.split('\n')
let inCodeBlock = false
let codeContent = ''
let codeLang = ''
let blockKey = 0
for (const line of lines) {
if (line.startsWith('```') && !inCodeBlock) {
inCodeBlock = true
codeLang = line.slice(3).trim()
codeContent = ''
continue
}
if (line.startsWith('```') && inCodeBlock) {
inCodeBlock = false
const code = codeContent.trimEnd()
// For JSON blocks that look like design data, use the collapsed view
if (codeLang === 'json' && isDesignJson(code)) {
parts.push(
<DesignJsonBlock
key={`design-${blockKey++}`}
code={code}
onApply={onApplyDesign}
isApplied={isApplied}
/>,
)
} else {
parts.push(
<CodeBlock
key={`code-${blockKey++}`}
code={code}
language={codeLang}
/>,
)
}
continue
}
if (inCodeBlock) {
codeContent += (codeContent ? '\n' : '') + line
continue
}
// Empty lines → plain newline (parent has whitespace-pre-wrap)
if (!line) {
parts.push('\n')
continue
}
parts.push(
<span key={`line-${blockKey++}`}>
{parseInlineMarkdown(line)}
{'\n'}
</span>,
)
}
// Handle unclosed code block (streaming)
if (inCodeBlock && codeContent) {
const code = codeContent.trimEnd()
if (codeLang === 'json' && isDesignJson(code)) {
parts.push(
<DesignJsonBlock
key={`design-${blockKey++}`}
code={code}
isStreaming
/>,
)
} else {
parts.push(
<CodeBlock
key={`code-${blockKey++}`}
code={code}
language={codeLang}
/>,
)
}
}
return parts
}
function parseInlineMarkdown(text: string): ReactNode[] | string {
// Fast path: no markdown syntax at all → return plain string (no wrapper spans)
if (!/[*`]/.test(text)) return text
const parts: ReactNode[] = []
let remaining = text
let key = 0
while (remaining.length > 0) {
// Bold
const boldMatch = remaining.match(/\*\*(.+?)\*\*/)
// Inline code
const codeMatch = remaining.match(/`([^`]+)`/)
// Italic
const italicMatch = remaining.match(/\*(.+?)\*/)
const matches = [
boldMatch && { match: boldMatch, type: 'bold' as const },
codeMatch && { match: codeMatch, type: 'code' as const },
italicMatch && { match: italicMatch, type: 'italic' as const },
]
.filter(Boolean)
.sort((a, b) => a!.match.index! - b!.match.index!)
if (matches.length === 0) {
parts.push(remaining)
break
}
const first = matches[0]!
const idx = first.match.index!
if (idx > 0) {
parts.push(remaining.slice(0, idx))
}
if (first.type === 'bold') {
parts.push(
<strong key={key++} className="font-semibold">
{first.match[1]}
</strong>,
)
} else if (first.type === 'code') {
parts.push(
<code
key={key++}
className="bg-secondary text-foreground/80 px-1 py-0.5 rounded text-[0.85em]"
>
{first.match[1]}
</code>,
)
} else {
parts.push(
<em key={key++} className="italic">
{first.match[1]}
</em>,
)
}
remaining = remaining.slice(idx + first.match[0].length)
}
return parts
}
function CodeBlock({ code, language }: { code: string; language: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="my-2 rounded-md overflow-hidden bg-background border border-border">
<div className="flex items-center justify-between px-3 py-1 bg-card border-b border-border">
<span className="text-[10px] text-muted-foreground uppercase">{language || 'code'}</span>
<button
type="button"
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
title="Copy code"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
<pre className="p-3 overflow-x-auto text-xs leading-relaxed">
<code className="text-foreground/80">{code}</code>
</pre>
</div>
)
}
/** Collapsed design JSON block — shows element count + expand toggle */
function DesignJsonBlock({
code,
onApply,
isApplied,
isStreaming,
}: {
code: string
onApply?: (json: string) => void
isApplied?: boolean
isStreaming?: boolean
}) {
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const elementCount = useMemo(() => {
try {
const parsed = JSON.parse(code)
if (Array.isArray(parsed)) return parsed.length
return 1
} catch {
return 0
}
}, [code])
const handleCopy = () => {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="my-2 rounded-md bg-background border border-border overflow-hidden">
{/* 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"
>
<div className="flex items-center gap-1.5">
{expanded ? (
<ChevronDown size={12} className="text-muted-foreground" />
) : (
<ChevronRight size={12} className="text-muted-foreground" />
)}
<Wand2 size={12} className="text-primary" />
<span className="text-xs text-foreground">
{isStreaming
? 'Generating design...'
: `${elementCount} design element${elementCount !== 1 ? 's' : ''}`}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
title="Copy JSON"
>
{copied ? <Check size={10} /> : <Copy size={10} />}
</button>
</button>
{/* Expandable JSON content */}
{expanded && (
<pre className="p-3 overflow-x-auto text-xs leading-relaxed max-h-48 overflow-y-auto border-t border-border">
<code className="text-muted-foreground">{code}</code>
</pre>
)}
{/* Apply button (only if not already applied and handler exists) */}
{onApply && !isApplied && !isStreaming && (
<Button
onClick={() => onApply(code)}
className="w-full rounded-none border-t border-border"
size="sm"
>
<Wand2 size={12} />
Apply to Canvas
</Button>
)}
</div>
)
}
export default function ChatMessage({
role,
content,
isStreaming,
onApplyDesign,
}: ChatMessageProps) {
const isApplied = useMemo(
() => role === 'assistant' && content.includes('\u2705'),
[role, content],
)
const isUser = role === 'user'
const isEmpty = !content.trim()
// Don't render an empty non-streaming assistant message
if (!isUser && isEmpty && !isStreaming) return null
return (
<div className={cn('flex mb-3', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap',
isUser
? 'bg-primary text-primary-foreground rounded-br-sm'
: 'bg-secondary text-foreground rounded-bl-sm',
)}
>
{/* Streaming with no content yet → thinking indicator */}
{!isUser && isEmpty && isStreaming ? (
<div className="flex items-center gap-1.5 py-0.5">
<span className="text-xs text-muted-foreground">Thinking</span>
<span className="flex gap-0.5">
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
) : isUser ? (
content
) : (
parseMarkdown(content, onApplyDesign, isApplied)
)}
{/* Streaming cursor — only when there IS content */}
{isStreaming && !isEmpty && (
<span className="inline-block w-1.5 h-4 bg-muted-foreground animate-pulse ml-0.5 align-text-bottom" />
)}
</div>
</div>
)
}

View file

@ -0,0 +1,102 @@
const PEN_NODE_SCHEMA = `
PenNode types (the ONLY format you output for designs):
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), children[], cornerRadius, fill, stroke, effects
- 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
All nodes share: id (string), type, name, x, y, rotation, opacity
Fill = [{ type: "solid", color: "#hex" }] or [{ type: "linear_gradient", angle: number, stops: [{ offset: 0-1, color: "#hex" }] }]
Stroke = { thickness: number, fill: [{ type: "solid", color: "#hex" }] }
Effects = [{ type: "shadow", offsetX, offsetY, blur, spread, color }]
RULES:
- cornerRadius is a number, NOT an object
- fill is ALWAYS an array
- Children inside layout frames MUST have explicit numeric width and height
- Do NOT set x/y on children inside layout frames the engine positions them
- Only set x/y on the ROOT frame
- Use "fill_container" as width/height string for children that should stretch to fill their parent
`
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" }] }] }
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" }] }] }
`
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.
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.
When a user asks non-design questions (explain, suggest colors, give advice), respond in text.
${DESIGN_EXAMPLES}
LAYOUT ENGINE:
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems
- Do NOT set x/y on children inside layout containers
- Every child in a layout frame MUST have explicit numeric width and height
- Use nested frames for complex layouts
DESIGN GUIDELINES:
- Mobile screens: root frame 375x812 at x:0,y:0. Web: 1200x800
- Use unique descriptive IDs
- All elements INSIDE root frame as children no floating elements
- Max 3-4 levels of nesting
- 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`
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON.
${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
DO NOT output bullet points, design descriptions, or explanations BEFORE the JSON.
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.
${DESIGN_EXAMPLES}
STRUCTURE:
- Single root frame containing ALL elements as children
- Root uses layout: "vertical" with gap and padding
- Sections are horizontal/vertical frames nested inside
- Max 4 levels of nesting
- Use gap and padding for spacing never manual x/y inside layout containers
SIZING:
- Mobile screens: root frame 375x812
- Web layouts: root frame 1200x800
- Every child MUST have explicit numeric width and height
- Use unique descriptive IDs
- All colors as fill arrays: [{ "type": "solid", "color": "#hex" }]
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette.`
export const CODE_GENERATOR_PROMPT = `You are a code generation engine for OpenPencil. Convert PenNode design descriptions into clean, production-ready code.
${PEN_NODE_SCHEMA}
Given a design structure (PenNode tree), generate the requested code format:
For react-tailwind: React functional component with Tailwind CSS classes and semantic HTML.
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.`

View file

@ -0,0 +1,98 @@
import type { AIStreamChunk } from './ai-types'
/**
* Streams a chat response from the server-side AI endpoint.
* The server uses ANTHROPIC_API_KEY from the environment (no client-side key needed).
*/
export async function* streamChat(
systemPrompt: string,
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
): AsyncGenerator<AIStreamChunk> {
try {
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system: systemPrompt, messages }),
})
if (!response.ok) {
const errBody = await response.text()
yield { type: 'error', content: `Server error: ${response.status} ${errBody}` }
return
}
const reader = response.body?.getReader()
if (!reader) {
yield { type: 'error', content: 'No response stream available' }
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Parse SSE events from the buffer
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (!data) continue
try {
const chunk = JSON.parse(data) as AIStreamChunk
yield chunk
} catch {
// Skip malformed lines
}
}
}
}
// Process remaining buffer
if (buffer.startsWith('data: ')) {
const data = buffer.slice(6).trim()
if (data) {
try {
yield JSON.parse(data) as AIStreamChunk
} catch {
// Skip
}
}
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error occurred'
yield { type: 'error', content: message }
}
}
/**
* Non-streaming completion for design/code generation.
* Calls the server-side endpoint which reads ANTHROPIC_API_KEY from env.
*/
export async function generateCompletion(
systemPrompt: string,
userMessage: string,
): Promise<string> {
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system: systemPrompt, message: userMessage }),
})
if (!response.ok) {
throw new Error(`Server error: ${response.status}`)
}
const data = await response.json()
if (data.error) {
throw new Error(data.error)
}
return data.text ?? ''
}

View file

@ -0,0 +1,27 @@
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
isStreaming?: boolean
}
export interface AIDesignRequest {
prompt: string
context?: {
selectedNodes?: string[]
documentSummary?: string
canvasSize?: { width: number; height: number }
}
}
export interface AICodeRequest {
prompt?: string
format: 'react-tailwind' | 'html-css' | 'react-inline'
nodeIds?: string[]
}
export interface AIStreamChunk {
type: 'text' | 'done' | 'error'
content: string
}

View file

@ -0,0 +1,89 @@
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 { useDocumentStore } from '@/stores/document-store'
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 {
const parsed = JSON.parse(rawJson.trim())
const nodes: PenNode[] = Array.isArray(parsed) ? parsed : [parsed]
return validateNodes(nodes) ? nodes : null
} catch {
return null
}
}
function validateNodes(nodes: unknown[]): nodes is PenNode[] {
return nodes.every(
(node) =>
typeof node === 'object' &&
node !== null &&
'id' in node &&
'type' in node &&
typeof (node as PenNode).id === 'string' &&
typeof (node as PenNode).type === 'string',
)
}
function buildContextMessage(request: AIDesignRequest): string {
let message = request.prompt
if (request.context?.canvasSize) {
const { width, height } = request.context.canvasSize
message += `\n\nCanvas size: ${width}x${height}px`
}
if (request.context?.documentSummary) {
message += `\n\nCurrent document: ${request.context.documentSummary}`
}
return message
}
export async function generateDesign(
request: AIDesignRequest,
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
const userMessage = buildContextMessage(request)
let fullResponse = ''
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
{ role: 'user', content: userMessage },
])) {
if (chunk.type === 'text') {
fullResponse += chunk.content
} else if (chunk.type === 'error') {
throw new Error(chunk.content)
}
}
const nodes = extractJsonFromResponse(fullResponse)
if (!nodes || nodes.length === 0) {
throw new Error('Failed to parse design nodes from AI response')
}
return { nodes, rawResponse: fullResponse }
}
export function applyNodesToCanvas(nodes: PenNode[]): void {
const { addNode } = useDocumentStore.getState()
for (const node of nodes) {
addNode(null, node)
}
}
/**
* Extract PenNode JSON from AI response text and apply to canvas.
* Returns the number of top-level elements added (0 if nothing found/applied).
*/
export function extractAndApplyDesign(responseText: string): number {
const nodes = extractJsonFromResponse(responseText)
if (!nodes || nodes.length === 0) return 0
applyNodesToCanvas(nodes)
return nodes.length
}

68
src/stores/ai-store.ts Normal file
View file

@ -0,0 +1,68 @@
import { create } from 'zustand'
import type { ChatMessage } from '@/services/ai/ai-types'
export type PanelCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
interface AIState {
messages: ChatMessage[]
isStreaming: boolean
isPanelOpen: boolean
activeTab: 'chat' | 'code'
generatedCode: string
codeFormat: 'react-tailwind' | 'html-css' | 'react-inline'
panelCorner: PanelCorner
isMinimized: boolean
addMessage: (msg: ChatMessage) => void
updateLastMessage: (content: string) => void
setStreaming: (v: boolean) => void
togglePanel: () => void
setPanelOpen: (open: boolean) => void
setActiveTab: (tab: 'chat' | 'code') => void
setGeneratedCode: (code: string) => void
setCodeFormat: (f: 'react-tailwind' | 'html-css' | 'react-inline') => void
clearMessages: () => void
setPanelCorner: (corner: PanelCorner) => void
toggleMinimize: () => void
}
export const useAIStore = create<AIState>((set) => ({
messages: [],
isStreaming: false,
isPanelOpen: true,
activeTab: 'chat',
generatedCode: '',
codeFormat: 'react-tailwind',
panelCorner: 'bottom-right',
isMinimized: false,
addMessage: (msg) =>
set((s) => ({ messages: [...s.messages, msg] })),
updateLastMessage: (content) =>
set((s) => {
const msgs = [...s.messages]
const last = msgs[msgs.length - 1]
if (last && last.role === 'assistant') {
msgs[msgs.length - 1] = { ...last, content }
}
return { messages: msgs }
}),
setStreaming: (isStreaming) => set({ isStreaming }),
togglePanel: () => set((s) => ({ isPanelOpen: !s.isPanelOpen })),
setPanelOpen: (isPanelOpen) => set({ isPanelOpen }),
setActiveTab: (activeTab) => set({ activeTab }),
setGeneratedCode: (generatedCode) => set({ generatedCode }),
setCodeFormat: (codeFormat) => set({ codeFormat }),
clearMessages: () => set({ messages: [] }),
setPanelCorner: (panelCorner) => set({ panelCorner }),
toggleMinimize: () => set((s) => ({ isMinimized: !s.isMinimized })),
}))