mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
b425defdca
commit
6a7a0d0019
9 changed files with 1422 additions and 0 deletions
149
server/api/ai/chat.ts
Normal file
149
server/api/ai/chat.ts
Normal 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
90
server/api/ai/generate.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
462
src/components/panels/ai-chat-panel.tsx
Normal file
462
src/components/panels/ai-chat-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
337
src/components/panels/chat-message.tsx
Normal file
337
src/components/panels/chat-message.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
src/services/ai/ai-prompts.ts
Normal file
102
src/services/ai/ai-prompts.ts
Normal 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.`
|
||||
98
src/services/ai/ai-service.ts
Normal file
98
src/services/ai/ai-service.ts
Normal 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 ?? ''
|
||||
}
|
||||
27
src/services/ai/ai-types.ts
Normal file
27
src/services/ai/ai-types.ts
Normal 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
|
||||
}
|
||||
89
src/services/ai/design-generator.ts
Normal file
89
src/services/ai/design-generator.ts
Normal 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
68
src/stores/ai-store.ts
Normal 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 })),
|
||||
}))
|
||||
Loading…
Reference in a new issue