feat(electron): enhance Electron app integration and CI/CD workflows
- Add new commands for Electron development, compilation, and building processes in CLAUDE.md. - Update README.md to reflect the availability of the application as both a web and desktop app. - Introduce a FixedChecklist component in the AI chat panel for better user interaction with generated tasks. - Implement CI/CD workflows for automated testing and Electron builds in GitHub Actions. - Refactor design generator prompts to support element-by-element streaming for improved performance.
This commit is contained in:
parent
523f96a2f7
commit
80dcda0bf1
88
.github/workflows/build-electron.yml
vendored
Normal file
88
.github/workflows/build-electron.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: Build Electron
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build web (electron target)
|
||||
run: bun --bun run build
|
||||
env:
|
||||
BUILD_TARGET: electron
|
||||
|
||||
- name: Compile electron
|
||||
run: bun run electron:compile
|
||||
|
||||
- name: Build Electron app
|
||||
run: npx electron-builder --config electron-builder.yml --${{ matrix.platform }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: electron-${{ matrix.platform }}
|
||||
path: |
|
||||
dist-electron/*.dmg
|
||||
dist-electron/*.zip
|
||||
dist-electron/*.exe
|
||||
dist-electron/*.AppImage
|
||||
dist-electron/*.deb
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: artifacts/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, v0.0.1]
|
||||
pull_request:
|
||||
branches: [main, v0.0.1]
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
name: Lint & Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: bun --bun run test
|
||||
|
||||
build-web:
|
||||
name: Build Web
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: lint-and-test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun --bun run build
|
||||
|
||||
- name: Upload web build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-build
|
||||
path: .output/
|
||||
retention-days: 7
|
||||
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -11,16 +11,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
- **Run a single test:** `bun --bun vitest run path/to/test.ts`
|
||||
- **Type check:** `npx tsc --noEmit`
|
||||
- **Install dependencies:** `bun install`
|
||||
- **Electron dev:** `bun run electron:dev` (starts Vite + Electron together)
|
||||
- **Electron compile:** `bun run electron:compile` (esbuild electron/ to electron-dist/)
|
||||
- **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package)
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Built as a **TanStack Start** full-stack React application with Bun runtime. Server API powered by **Nitro**.
|
||||
OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Built as a **TanStack Start** full-stack React application with Bun runtime. Server API powered by **Nitro**. Also ships as an **Electron** desktop app for macOS, Windows, and Linux.
|
||||
|
||||
**Key technologies:** React 19, Fabric.js v7 (canvas engine), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), TypeScript (strict mode).
|
||||
**Key technologies:** React 19, Fabric.js v7 (canvas engine), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode).
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
```text
|
||||
React Components (Toolbar, LayerPanel, PropertyPanel)
|
||||
│ Zustand hooks
|
||||
▼
|
||||
|
|
@ -43,7 +46,7 @@ React Components (Toolbar, LayerPanel, PropertyPanel)
|
|||
|
||||
### Design Variables Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
PenDocument (source of truth)
|
||||
├── variables: Record<string, VariableDefinition> ($color-1, $spacing-md, ...)
|
||||
├── themes: Record<string, string[]> ({Theme-1: ["Default","Dark"]})
|
||||
|
|
@ -155,6 +158,20 @@ File-based routing via TanStack Router. Routes in `src/routes/`, auto-generated
|
|||
|
||||
Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`src/components/ui/`). Icons from `lucide-react`. shadcn/ui config in `components.json`.
|
||||
|
||||
### Electron Desktop App
|
||||
|
||||
- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, macOS traffic-light padding
|
||||
- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC
|
||||
- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb)
|
||||
- **`scripts/electron-dev.ts`** — Dev workflow: starts Vite → waits for port 3000 → compiles electron/ with esbuild → launches Electron
|
||||
- Build flow: `BUILD_TARGET=electron bun run build` → `bun run electron:compile` → `npx electron-builder`
|
||||
- In production, Nitro server is forked as a child process on a random port; Electron loads `http://127.0.0.1:{port}/editor`
|
||||
|
||||
### CI / CD
|
||||
|
||||
- **`.github/workflows/ci.yml`** — Push/PR: type check (`tsc --noEmit`), tests (`vitest`), web build
|
||||
- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for macOS, Windows, Linux in parallel, creates draft GitHub Release with all artifacts
|
||||
|
||||
## Code Style
|
||||
|
||||
- 单个文件不要超过 800 行。超出时应拆分为更小的模块。
|
||||
|
|
|
|||
81
README.md
81
README.md
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Open-source vector design tool with a Design-as-Code philosophy. An alternative to [Pencil.dev](https://pencil.dev).
|
||||
|
||||
Available as a **web app** and **desktop app** (macOS / Windows / Linux via Electron).
|
||||
|
||||
## Features
|
||||
|
||||
### Canvas
|
||||
|
|
@ -132,12 +134,16 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
- **Styling:** [Tailwind CSS](https://tailwindcss.com/) v4
|
||||
- **Icons:** [Lucide React](https://lucide.dev/)
|
||||
- **Server:** [Nitro](https://nitro.build/) (API routes)
|
||||
- **Desktop:** [Electron](https://www.electronjs.org/) 35 + [electron-builder](https://www.electron.build/)
|
||||
- **AI:** [Anthropic SDK](https://docs.anthropic.com/) + [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk)
|
||||
- **Runtime:** [Bun](https://bun.sh/)
|
||||
- **Build:** [Vite](https://vite.dev/) 7
|
||||
- **CI/CD:** GitHub Actions
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Web (Development)
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun --bun run dev
|
||||
|
|
@ -145,6 +151,16 @@ bun --bun run dev
|
|||
|
||||
Open http://localhost:3000 and click "New Design" to enter the editor.
|
||||
|
||||
### Electron (Desktop)
|
||||
|
||||
```bash
|
||||
# Development: starts Vite dev server + Electron
|
||||
bun run electron:dev
|
||||
|
||||
# Production build (current platform)
|
||||
bun run electron:build
|
||||
```
|
||||
|
||||
### AI Configuration
|
||||
|
||||
The AI assistant works in two modes:
|
||||
|
|
@ -156,40 +172,50 @@ The AI assistant works in two modes:
|
|||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `bun --bun run dev` | Start dev server on port 3000 |
|
||||
| `bun --bun run build` | Production build |
|
||||
| `bun --bun run dev` | Start web dev server on port 3000 |
|
||||
| `bun --bun run build` | Production web build |
|
||||
| `bun --bun run preview` | Preview production build |
|
||||
| `bun --bun run test` | Run tests (Vitest) |
|
||||
| `npx tsc --noEmit` | Type check |
|
||||
| `bun run electron:dev` | Start Vite + Electron for desktop dev |
|
||||
| `bun run electron:compile` | Compile electron/ with esbuild |
|
||||
| `bun run electron:build` | Full Electron package (web build + compile + electron-builder) |
|
||||
|
||||
## CI / CD
|
||||
|
||||
### CI (`ci.yml`)
|
||||
|
||||
Runs on every push and PR to `main` / `v0.0.1`:
|
||||
|
||||
1. **Lint & Test** — type check (`tsc --noEmit`) + unit tests (`vitest`)
|
||||
2. **Build Web** — production web build, uploads `.output/` as artifact
|
||||
|
||||
### Build Electron (`build-electron.yml`)
|
||||
|
||||
Triggered by version tags (`v*`) or manual dispatch:
|
||||
|
||||
1. **Build** — parallel matrix across macOS, Windows, Linux
|
||||
- macOS: `.dmg` + `.zip`
|
||||
- Windows: `.exe` (NSIS installer + portable)
|
||||
- Linux: `.AppImage` + `.deb`
|
||||
2. **Release** — creates a draft GitHub Release with all platform artifacts
|
||||
|
||||
To create a release:
|
||||
|
||||
```bash
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
src/
|
||||
canvas/ # Fabric.js canvas engine (16 files)
|
||||
fabric-canvas.tsx Canvas component initialization
|
||||
canvas-object-factory Creates Fabric objects from PenNodes
|
||||
canvas-object-sync Syncs object properties Fabric ↔ store
|
||||
canvas-sync-lock Prevents circular sync loops
|
||||
canvas-controls Custom rotation controls and cursors
|
||||
canvas-constants Default colors, zoom limits
|
||||
use-canvas-events Drawing events, tool management
|
||||
use-canvas-sync Bidirectional PenDocument ↔ Fabric sync + variable resolution
|
||||
use-canvas-viewport Zoom, pan, tool cursor switching
|
||||
use-canvas-selection Selection sync Fabric ↔ store
|
||||
use-canvas-guides Smart alignment guides
|
||||
guide-utils Guide calculation and rendering
|
||||
pen-tool Bezier pen tool with anchors/handles
|
||||
parent-child-transform Parent transform propagation to children
|
||||
use-dimension-label Size/position labels during manipulation
|
||||
use-frame-labels Frame name/boundary rendering
|
||||
canvas/ # Fabric.js canvas engine
|
||||
variables/ # Design variables/tokens system
|
||||
resolve-variables Core $variable resolution for canvas rendering
|
||||
replace-refs Replace/resolve $refs on rename/delete
|
||||
components/
|
||||
editor/ # Editor layout, toolbar, tool buttons, status bar
|
||||
panels/ # Layer panel, property panel (17 files), AI chat, code panel,
|
||||
# variables panel, variable row
|
||||
panels/ # Layer panel, property panel, AI chat, code panel, variables panel
|
||||
shared/ # ColorPicker, NumberInput, VariablePicker, ExportDialog, etc.
|
||||
icons/ # Provider logos (Claude, OpenAI)
|
||||
ui/ # shadcn/ui primitives (Button, Select, Slider, Switch, etc.)
|
||||
|
|
@ -202,8 +228,15 @@ src/
|
|||
types/ # PenDocument/PenNode types, style types, variables, agent settings
|
||||
utils/ # File operations, export, node clone, SVG parser, syntax highlight
|
||||
routes/ # TanStack Router pages (/, /editor)
|
||||
electron/
|
||||
main.ts # Electron main process (window, Nitro server, IPC)
|
||||
preload.ts # Context bridge for renderer ↔ main IPC
|
||||
server/
|
||||
api/ai/ # Nitro API: streaming chat, generation, agent connection, models
|
||||
.github/
|
||||
workflows/
|
||||
ci.yml # CI: type check, test, web build
|
||||
build-electron.yml # Electron build for macOS/Windows/Linux + GitHub Release
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2 } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2, Pencil } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -24,7 +24,11 @@ import type { AIProviderType } from '@/types/agent-settings'
|
|||
import ClaudeLogo from '@/components/icons/claude-logo'
|
||||
import OpenAILogo from '@/components/icons/openai-logo'
|
||||
import OpenCodeLogo from '@/components/icons/opencode-logo'
|
||||
import ChatMessage from './chat-message'
|
||||
import ChatMessage, {
|
||||
parseStepBlocks,
|
||||
countDesignJsonBlocks,
|
||||
buildPipelineProgress,
|
||||
} from './chat-message'
|
||||
|
||||
const PROVIDER_ICON: Record<AIProviderType, typeof ClaudeLogo> = {
|
||||
anthropic: ClaudeLogo,
|
||||
|
|
@ -274,6 +278,88 @@ function useChatHandlers() {
|
|||
return { input, setInput, handleSend, isStreaming }
|
||||
}
|
||||
|
||||
/** Fixed collapsible checklist pinned between messages and input */
|
||||
function FixedChecklist({ messages, isStreaming }: { messages: ChatMessageType[]; isStreaming: boolean }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
// Find the last assistant message to extract checklist data
|
||||
const lastAssistant = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'assistant') return messages[i]
|
||||
}
|
||||
return null
|
||||
}, [messages])
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!lastAssistant) return []
|
||||
const content = lastAssistant.content
|
||||
const steps = parseStepBlocks(content, isStreaming)
|
||||
const planSteps = steps.filter((s) => s.title !== 'Thinking')
|
||||
if (planSteps.length === 0) return []
|
||||
const jsonCount = countDesignJsonBlocks(content)
|
||||
const isApplied = content.includes('\u2705') || content.includes('<!-- APPLIED -->')
|
||||
const hasError = /\*\*Error:\*\*/i.test(content)
|
||||
return buildPipelineProgress(planSteps, jsonCount, isStreaming, isApplied, hasError)
|
||||
}, [lastAssistant, isStreaming])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const completed = items.filter((item) => item.done).length
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card/95">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center justify-between w-full px-3 py-2 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil size={13} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground">Pencil it out</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{completed}/{items.length}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
'text-muted-foreground transition-transform duration-200',
|
||||
collapsed ? '' : 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="px-3 pb-2.5 flex flex-col gap-1">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex items-center gap-2 text-[11px] text-muted-foreground/90">
|
||||
<span
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0',
|
||||
item.done
|
||||
? 'border-emerald-500/70 text-emerald-500/80'
|
||||
: item.active
|
||||
? 'border-primary/70 text-primary'
|
||||
: 'border-border/70 text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
{item.done ? (
|
||||
<Check size={9} strokeWidth={2.5} />
|
||||
) : (
|
||||
<span className={cn(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
item.active ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60',
|
||||
)} />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(item.active ? 'text-foreground' : '')}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimized AI bar — a compact clickable pill.
|
||||
* Parent is responsible for placing it in the layout.
|
||||
|
|
@ -678,6 +764,9 @@ export default function AIChatPanel() {
|
|||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* --- Fixed Checklist --- */}
|
||||
<FixedChecklist messages={messages} isStreaming={isStreaming} />
|
||||
|
||||
{/* --- Input area --- */}
|
||||
<div className="relative border-t border-border bg-card rounded-b-xl">
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -46,23 +46,12 @@ function stripToolCallXml(text: string): string {
|
|||
return cleaned.trim()
|
||||
}
|
||||
|
||||
interface ParsedStep {
|
||||
export interface ParsedStep {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const DESIGN_PIPELINE_TASKS = [
|
||||
'Create sidebar navigation with mission control sections',
|
||||
'Add system status panel with telemetry data',
|
||||
'Build main header with launch controls',
|
||||
'Create mission metrics cards row',
|
||||
'Add rocket visualization with futuristic image',
|
||||
'Build launch sequence panel',
|
||||
'Add mission timeline/countdown section',
|
||||
'Final spacing and visual consistency pass',
|
||||
]
|
||||
|
||||
function parseStepBlocks(text: string, isStreaming?: boolean): ParsedStep[] {
|
||||
export function parseStepBlocks(text: string, isStreaming?: boolean): ParsedStep[] {
|
||||
const stepRegex = /<step(?:[^>]*title="([^"]+)")?[^>]*>([\s\S]*?)<\/step>/gi
|
||||
const parsed: ParsedStep[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
|
@ -102,58 +91,87 @@ function stripStepBlocks(text: string): string {
|
|||
.trim()
|
||||
}
|
||||
|
||||
function countDesignJsonBlocks(text: string): number {
|
||||
const blockRegex = /```(?:json)?\s*([\s\S]*?)```/gi
|
||||
/** Count completed sections in JSONL content (direct children of root frame). */
|
||||
function countJsonlSections(content: string): number {
|
||||
const lines = content.split('\n')
|
||||
let rootId: string | null = null
|
||||
let sectionCount = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('{')) continue
|
||||
|
||||
const parentMatch = trimmed.match(/"_parent"\s*:\s*(null|"([^"]*)")/)
|
||||
if (!parentMatch) continue
|
||||
|
||||
if (parentMatch[1] === 'null') {
|
||||
const idMatch = trimmed.match(/"id"\s*:\s*"([^"]*)"/)
|
||||
if (idMatch) rootId = idMatch[1]
|
||||
} else if (rootId && parentMatch[2] === rootId) {
|
||||
sectionCount++
|
||||
}
|
||||
}
|
||||
|
||||
return sectionCount
|
||||
}
|
||||
|
||||
export function countDesignJsonBlocks(text: string): number {
|
||||
const blockRegex = /```(?:json)?\s*\n?([\s\S]*?)(?:\n?```|$)/gi
|
||||
let count = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = blockRegex.exec(text)) !== null) {
|
||||
if (isDesignJson(match[1])) count += 1
|
||||
const content = match[1].trim()
|
||||
if (!isDesignJson(content)) continue
|
||||
|
||||
// JSONL format: count direct children of root as sections
|
||||
if (/"_parent"\s*:/.test(content)) {
|
||||
count += countJsonlSections(content)
|
||||
} else {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function buildPipelineProgress(
|
||||
export function buildPipelineProgress(
|
||||
steps: ParsedStep[],
|
||||
jsonBlockCount: number,
|
||||
isStreaming: boolean,
|
||||
isApplied: boolean,
|
||||
hasError: boolean,
|
||||
): Array<{ label: string; done: boolean; active: boolean }> {
|
||||
// No steps = no checklist
|
||||
if (steps.length === 0) return []
|
||||
|
||||
// If generation is complete and applied, mark all steps done
|
||||
const hasTerminalResult = !isStreaming && !hasError && (isApplied || jsonBlockCount > 0)
|
||||
if (hasTerminalResult) {
|
||||
return DESIGN_PIPELINE_TASKS.map((label) => ({ label, done: true, active: false }))
|
||||
return steps.map((s) => ({ label: s.title, done: true, active: false }))
|
||||
}
|
||||
|
||||
const lowerTitles = new Set(steps.map((s) => s.title.toLowerCase()))
|
||||
const hasGuidelines = [...lowerTitles].some((t) => t.includes('guidelines'))
|
||||
const hasEditorState = [...lowerTitles].some((t) => t.includes('editor state') || t.includes('state'))
|
||||
const hasStyleGuide = [...lowerTitles].some((t) => t.includes('styleguide') || t.includes('style guide'))
|
||||
|
||||
const doneCount = Math.min(
|
||||
DESIGN_PIPELINE_TASKS.length,
|
||||
(hasGuidelines ? 1 : 0) +
|
||||
(hasEditorState ? 1 : 0) +
|
||||
(hasStyleGuide ? 1 : 0) +
|
||||
Math.min(4, jsonBlockCount) +
|
||||
(isApplied ? 1 : 0),
|
||||
)
|
||||
|
||||
return DESIGN_PIPELINE_TASKS.map((label, index) => {
|
||||
const done = index < doneCount
|
||||
const active = isStreaming && !done && index === doneCount
|
||||
return { label, done, active }
|
||||
// Map each step to done/active/pending based on completed JSON blocks.
|
||||
// Step[i] is done when jsonBlockCount > i.
|
||||
// The step at jsonBlockCount is active (currently being generated).
|
||||
return steps.map((s, index) => {
|
||||
const done = index < jsonBlockCount
|
||||
const active = isStreaming && !done && index === jsonBlockCount
|
||||
return { label: s.title, done, active }
|
||||
})
|
||||
}
|
||||
|
||||
/** Component for rendering a list of action steps as accordions */
|
||||
/** Component for rendering a list of action steps as accordions.
|
||||
* Only shows steps with non-empty content (e.g. thinking, analysis).
|
||||
* Empty plan steps are shown in PipelineChecklist instead. */
|
||||
function ActionSteps({ steps, isStreaming }: { steps: ParsedStep[]; isStreaming?: boolean }) {
|
||||
if (steps.length === 0) return null
|
||||
// Filter to only show steps with actual content (not empty plan steps)
|
||||
const stepsWithContent = steps.filter((s) => s.content.trim())
|
||||
if (stepsWithContent.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{steps.map((step, i) => {
|
||||
const isDone = !isStreaming || i < steps.length - 1
|
||||
const isActive = !!isStreaming && i === steps.length - 1
|
||||
{stepsWithContent.map((step, i) => {
|
||||
const isDone = !isStreaming || i < stepsWithContent.length - 1
|
||||
const isActive = !!isStreaming && i === stepsWithContent.length - 1
|
||||
return (
|
||||
<ActionStepItem
|
||||
key={`${step.title}-${i}`}
|
||||
|
|
@ -234,41 +252,6 @@ function ActionStepItem({
|
|||
)
|
||||
}
|
||||
|
||||
function PipelineChecklist({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ label: string; done: boolean; active: boolean }>
|
||||
}) {
|
||||
const completed = items.filter((item) => item.done).length
|
||||
return (
|
||||
<div className="mt-2 border-t border-border/40 pt-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">Pencil it out</span>
|
||||
<span className="text-[10px] text-muted-foreground">{completed}/{items.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex items-center gap-1.5 text-[10px] text-muted-foreground/90">
|
||||
<span
|
||||
className={cn(
|
||||
'w-3 h-3 rounded-full border flex items-center justify-center shrink-0',
|
||||
item.done
|
||||
? 'border-emerald-500/70 text-emerald-500/80'
|
||||
: item.active
|
||||
? 'border-primary/70 text-primary'
|
||||
: 'border-border/70 text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
{item.done ? <Check size={9} strokeWidth={2.5} /> : <span className={cn('w-1.5 h-1.5 rounded-full', item.active ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60')} />}
|
||||
</span>
|
||||
<span className={cn(item.active ? 'text-foreground' : '')}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Check if a JSON string looks like PenNode data */
|
||||
function isDesignJson(code: string): boolean {
|
||||
return /^\s*[\[{]/.test(code) && /"type"\s*:/.test(code) && /"id"\s*:/.test(code)
|
||||
|
|
@ -502,6 +485,10 @@ function DesignJsonBlock({
|
|||
if (Array.isArray(parsed)) return parsed.length
|
||||
return 1
|
||||
} catch {
|
||||
// JSONL format: count lines that look like JSON objects
|
||||
if (/"_parent"\s*:/.test(code)) {
|
||||
return code.split('\n').filter(line => line.trim().startsWith('{')).length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}, [code])
|
||||
|
|
@ -605,21 +592,6 @@ export default function ChatMessage({
|
|||
() => (isUser ? displayContent : stripStepBlocks(displayContent)),
|
||||
[isUser, displayContent],
|
||||
)
|
||||
const jsonBlockCount = useMemo(
|
||||
() => (isUser ? 0 : countDesignJsonBlocks(displayContent)),
|
||||
[isUser, displayContent],
|
||||
)
|
||||
const checklistItems = useMemo(
|
||||
() =>
|
||||
buildPipelineProgress(
|
||||
steps,
|
||||
jsonBlockCount,
|
||||
!!isStreaming,
|
||||
isApplied,
|
||||
/\*\*Error:\*\*/i.test(content),
|
||||
),
|
||||
[steps, jsonBlockCount, isStreaming, isApplied, content],
|
||||
)
|
||||
const isEmpty = !contentWithoutSteps.trim() && !hasFlow
|
||||
|
||||
// Don't render an empty non-streaming assistant message
|
||||
|
|
@ -662,7 +634,6 @@ export default function ChatMessage({
|
|||
{hasFlow && (
|
||||
<div className="mb-2">
|
||||
<ActionSteps steps={steps} isStreaming={isStreaming} />
|
||||
<PipelineChecklist items={checklistItems} />
|
||||
</div>
|
||||
)}
|
||||
{contentWithoutSteps.trim() ? (
|
||||
|
|
|
|||
|
|
@ -116,64 +116,61 @@ DESIGN VARIABLES:
|
|||
- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
|
||||
- Only reference variables that are listed — do NOT invent new variable names.`
|
||||
|
||||
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON.
|
||||
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON streaming engine. Convert design descriptions into flat PenNode JSON, one element at a time.
|
||||
|
||||
${PEN_NODE_SCHEMA}
|
||||
|
||||
OUTPUT FORMAT:
|
||||
1. You may include 1-2 brief <step> tags (optional, keep them SHORT — one line each).
|
||||
2. Output a SINGLE ${BLOCK}json code block containing the COMPLETE design as a PenNode JSON array.
|
||||
3. Add a 1-sentence summary after the JSON block.
|
||||
OUTPUT FORMAT — ELEMENT-BY-ELEMENT STREAMING:
|
||||
Each element is rendered to the canvas the INSTANT it finishes generating. Output flat JSON objects inside a single ${BLOCK}json block.
|
||||
|
||||
STEP 1 — PLAN (required):
|
||||
List ALL planned sections as <step> tags BEFORE the json block:
|
||||
<step title="Navigation bar"></step>
|
||||
<step title="Hero section"></step>
|
||||
<step title="Feature cards"></step>
|
||||
|
||||
STEP 2 — BUILD:
|
||||
Output a ${BLOCK}json block containing flat JSON objects, ONE PER LINE.
|
||||
Every node MUST have a "_parent" field:
|
||||
- Root frame: "_parent": null
|
||||
- All others: "_parent": "<parent-id>"
|
||||
|
||||
Output parent nodes BEFORE their children (depth-first order).
|
||||
Each line = one complete JSON object. NO multi-line formatting. NO nested "children" arrays.
|
||||
|
||||
EXAMPLE:
|
||||
<step title="Page structure"></step>
|
||||
<step title="Navigation"></step>
|
||||
<step title="Hero"></step>
|
||||
|
||||
${BLOCK}json
|
||||
{"_parent":null,"id":"page","type":"frame","name":"Page","x":0,"y":0,"width":375,"height":812,"layout":"vertical","gap":0,"fill":[{"type":"solid","color":"#16171B"}]}
|
||||
{"_parent":"page","id":"nav","type":"frame","name":"Nav","width":"fill_container","height":56,"layout":"horizontal","padding":16,"alignItems":"center","fill":[{"type":"solid","color":"#1E2026"}]}
|
||||
{"_parent":"nav","id":"logo","type":"text","name":"Logo","content":"App","fontSize":18,"fontWeight":700,"width":60,"height":22,"fill":[{"type":"solid","color":"#F4F4F5"}]}
|
||||
{"_parent":"nav","id":"menu-icon","type":"path","name":"MenuIcon","d":"M3 12h18M3 6h18M3 18h18","width":24,"height":24,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#F4F4F5"}]}}
|
||||
{"_parent":"page","id":"hero","type":"frame","name":"Hero","width":"fill_container","height":300,"layout":"vertical","padding":24,"gap":16,"alignItems":"center","justifyContent":"center"}
|
||||
{"_parent":"hero","id":"title","type":"text","name":"Title","content":"Welcome","fontSize":28,"fontWeight":700,"width":300,"height":36,"fill":[{"type":"solid","color":"#F4F4F5"}]}
|
||||
${BLOCK}
|
||||
|
||||
CRITICAL RULES:
|
||||
- Output ONE complete JSON block with ALL nodes — do NOT split into multiple phases.
|
||||
- Use a single root frame containing ALL elements as children.
|
||||
- Keep IDs unique and descriptive.
|
||||
- DO NOT WRITE ANY INTRODUCTORY TEXT.
|
||||
- Start generating JSON as quickly as possible — minimize preamble.
|
||||
- DO NOT use nested "children" arrays — each node is a FLAT JSON object with "_parent".
|
||||
- ONE JSON object per line — never split a node across lines.
|
||||
- Output parent before children (depth-first).
|
||||
- Root frame: "_parent": null, x:0, y:0.
|
||||
- Children of layout frames: NO x/y. Use width/height (or "fill_container").
|
||||
- Unique descriptive IDs. All colors as fill arrays.
|
||||
- Start with <step> tags, then immediately the json block. NO preamble text.
|
||||
- After the json block, add a 1-sentence summary.
|
||||
|
||||
DO NOT output bullet points, design descriptions, or explanations BEFORE the JSON (except <step> tags).
|
||||
DO NOT describe what you plan to create — just CREATE IT as JSON.
|
||||
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
|
||||
- If you need absolute decorative blobs, place them in a non-layout wrapper frame ("layout: \"none\"")
|
||||
|
||||
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" }]
|
||||
|
||||
ICONS & IMAGES:
|
||||
- Use "path" nodes for icons: provide SVG d attribute, set width/height (16-24px for UI icons), use stroke for line icons or fill for solid icons. Width and height MUST match the natural aspect ratio of the SVG path data — do not squeeze non-square logos into square dimensions
|
||||
- You can use icons from any popular Iconify collection: Lucide, Material Design Icons (mdi), Phosphor, Tabler Icons, Heroicons, Carbon, etc. Use the SVG path data you know from these icon sets
|
||||
- Use "image" nodes for photos/illustrations: set src to "https://picsum.photos/{width}/{height}" as placeholder, set explicit width/height
|
||||
- Include icons in buttons, nav items, list items, cards for professional polish
|
||||
- Reference the icon patterns in the examples section for common icons
|
||||
|
||||
VISUAL QUALITY GUARDRAILS:
|
||||
- Keep all interactive content in a safe area (at least 20px left/right padding on mobile)
|
||||
- Decorative blobs should be subtle and must not hide inputs/buttons/text
|
||||
- Avoid oversized decorations outside the root frame (max ~10% bleed allowed)
|
||||
- Do not use emoji in headings or body copy unless the user explicitly asks for it
|
||||
SIZING: Mobile root 375x812. Web root 1200x800.
|
||||
ICONS: "path" nodes with SVG d. Size 16-24px. Use Lucide/MDI/Heroicons paths.
|
||||
IMAGES: "image" nodes with src "https://picsum.photos/{w}/{h}".
|
||||
|
||||
DESIGN VARIABLES:
|
||||
- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
|
||||
- Color variables: use in fill color, stroke color, shadow color. Example: [{ "type": "solid", "color": "$primary" }]
|
||||
- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
|
||||
- Only reference variables that are listed — do NOT invent new variable names.
|
||||
- If no variables are provided, use hardcoded values as usual.
|
||||
- If DOCUMENT VARIABLES are provided, use "$name" refs instead of hardcoded values.
|
||||
- Only reference listed variables.
|
||||
|
||||
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette, purposeful iconography.`
|
||||
Design like a professional: 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts'
|
|||
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import {
|
||||
pendingAnimationNodes,
|
||||
markNodesForAnimation,
|
||||
startNewAnimationBatch,
|
||||
resetAnimationState,
|
||||
|
|
@ -38,6 +39,10 @@ function extractJsonFromResponse(text: string): PenNode[] | null {
|
|||
return selectBestNodeSet(parsedBlocks)
|
||||
}
|
||||
|
||||
// Try JSONL format (flat nodes with _parent field)
|
||||
const jsonlTree = parseJsonlToTree(text)
|
||||
if (jsonlTree) return jsonlTree
|
||||
|
||||
// Fallback: try to find a single JSON array if no blocks found
|
||||
const arrayMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/)
|
||||
if (arrayMatch) {
|
||||
|
|
@ -51,7 +56,7 @@ function extractJsonFromResponse(text: string): PenNode[] | null {
|
|||
if (directNodes) {
|
||||
return directNodes
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +144,153 @@ function extractAllJsonBlocks(text: string): string[] {
|
|||
return blocks
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming JSONL parser — extracts completed JSON objects from within
|
||||
// a ```json block as they stream in, enabling element-by-element rendering.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StreamingNodeResult {
|
||||
node: PenNode
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract completed JSON objects from streaming text (within a ```json block).
|
||||
* Uses brace-counting to detect complete objects before the block closes.
|
||||
* Each object is expected to have a `_parent` field for tree insertion.
|
||||
*/
|
||||
function extractStreamingNodes(
|
||||
text: string,
|
||||
processedOffset: number,
|
||||
): { results: StreamingNodeResult[]; newOffset: number } {
|
||||
// Find the start of the json block
|
||||
const jsonBlockStart = text.indexOf('```json')
|
||||
if (jsonBlockStart === -1) return { results: [], newOffset: processedOffset }
|
||||
|
||||
const contentStart = text.indexOf('\n', jsonBlockStart)
|
||||
if (contentStart === -1) return { results: [], newOffset: processedOffset }
|
||||
|
||||
const startPos = Math.max(processedOffset, contentStart + 1)
|
||||
|
||||
// Check if the block has ended (stop before closing ```)
|
||||
const blockEnd = text.indexOf('\n```', contentStart + 1)
|
||||
const searchEnd = blockEnd > 0 ? blockEnd : text.length
|
||||
|
||||
const results: StreamingNodeResult[] = []
|
||||
let i = startPos
|
||||
|
||||
while (i < searchEnd) {
|
||||
// Skip to next '{' character
|
||||
while (i < searchEnd && text[i] !== '{') i++
|
||||
if (i >= searchEnd) break
|
||||
|
||||
// Brace-counting to find matching '}'
|
||||
const objStart = i
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
let j = i
|
||||
|
||||
while (j < searchEnd) {
|
||||
const ch = text[j]
|
||||
if (escaped) { escaped = false; j++; continue }
|
||||
if (ch === '\\' && inString) { escaped = true; j++; continue }
|
||||
if (ch === '"') { inString = !inString; j++; continue }
|
||||
if (inString) { j++; continue }
|
||||
if (ch === '{') depth++
|
||||
else if (ch === '}') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
// Complete object found
|
||||
const objStr = text.slice(objStart, j + 1)
|
||||
try {
|
||||
const obj = JSON.parse(objStr) as Record<string, unknown>
|
||||
if (obj.id && obj.type) {
|
||||
const parentId = (obj._parent as string | null) ?? null
|
||||
delete obj._parent
|
||||
results.push({ node: obj as unknown as PenNode, parentId })
|
||||
}
|
||||
} catch { /* malformed JSON, skip */ }
|
||||
i = j + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (depth > 0) break // Incomplete object, wait for more data
|
||||
}
|
||||
|
||||
return { results, newOffset: i }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSONL-format response (flat nodes with _parent field) into a tree.
|
||||
* Used by extractAndApplyDesign for batch apply of JSONL content.
|
||||
*/
|
||||
function parseJsonlToTree(text: string): PenNode[] | null {
|
||||
const { results } = extractStreamingNodes(text, 0)
|
||||
if (results.length === 0) return null
|
||||
|
||||
const nodeMap = new Map<string, PenNode>()
|
||||
const roots: PenNode[] = []
|
||||
|
||||
for (const { node, parentId } of results) {
|
||||
nodeMap.set(node.id, node)
|
||||
|
||||
if (parentId === null) {
|
||||
roots.push(node)
|
||||
} else {
|
||||
const parent = nodeMap.get(parentId)
|
||||
if (parent) {
|
||||
if (!('children' in parent) || !Array.isArray((parent as PenNode & { children?: PenNode[] }).children)) {
|
||||
;(parent as PenNode & { children?: PenNode[] }).children = []
|
||||
}
|
||||
;(parent as PenNode & { children: PenNode[] }).children.push(node)
|
||||
} else {
|
||||
roots.push(node) // Parent not found, treat as root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roots.length > 0 ? roots : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single streaming node into the canvas with animation.
|
||||
* Handles root frame replacement and parent ID remapping.
|
||||
*/
|
||||
function insertStreamingNode(
|
||||
node: PenNode,
|
||||
parentId: string | null,
|
||||
): void {
|
||||
const { addNode, getNodeById } = useDocumentStore.getState()
|
||||
|
||||
// Ensure container nodes have children array for later child insertions
|
||||
if ((node.type === 'frame' || node.type === 'group') && !('children' in node)) {
|
||||
;(node as PenNode & { children: PenNode[] }).children = []
|
||||
}
|
||||
|
||||
// Resolve remapped parent IDs (e.g., root frame → DEFAULT_FRAME_ID)
|
||||
const resolvedParent = parentId
|
||||
? (generationRemappedIds.get(parentId) ?? parentId)
|
||||
: null
|
||||
|
||||
// Mark node for fade-in animation
|
||||
pendingAnimationNodes.add(node.id)
|
||||
startNewAnimationBatch()
|
||||
|
||||
if (resolvedParent === null && isCanvasOnlyEmptyFrame() && node.type === 'frame') {
|
||||
// Root frame replaces the default empty frame
|
||||
replaceEmptyFrame(node)
|
||||
} else {
|
||||
const effectiveParent = resolvedParent ?? DEFAULT_FRAME_ID
|
||||
// Verify parent exists, fall back to root frame
|
||||
const parent = getNodeById(effectiveParent)
|
||||
addNode(parent ? effectiveParent : DEFAULT_FRAME_ID, node)
|
||||
}
|
||||
}
|
||||
|
||||
function selectBestNodeSet(candidates: PenNode[][]): PenNode[] {
|
||||
let best = candidates[candidates.length - 1]
|
||||
let bestScore = scoreNodeSet(best)
|
||||
|
|
@ -191,69 +343,47 @@ export async function generateDesign(
|
|||
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
|
||||
const userMessage = buildContextMessage(request)
|
||||
let fullResponse = ''
|
||||
let processedBlockCount = 0
|
||||
let streamingOffset = 0 // Tracks how far we've parsed in the streaming text
|
||||
let appliedCount = 0
|
||||
let streamError: string | null = null
|
||||
const animated = callbacks?.animated ?? false
|
||||
|
||||
// Reset cross-phase ID remapping so that replaceEmptyFrame mappings
|
||||
// from a previous generation don't leak into this one.
|
||||
resetGenerationRemapping()
|
||||
|
||||
// Animation setup: single history batch + stagger state.
|
||||
// Nodes are inserted immediately (sync) via upsertNodesToCanvas.
|
||||
// Canvas-sync creates Fabric objects at opacity 0 and schedules
|
||||
// staggered fade-in via fire-and-forget setTimeout — no stream blocking.
|
||||
if (animated) {
|
||||
resetAnimationState()
|
||||
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
|
||||
let isThinking = false
|
||||
let thinkingContent = ''
|
||||
|
||||
try {
|
||||
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
|
||||
{ role: 'user', content: userMessage },
|
||||
], undefined, DESIGN_STREAM_TIMEOUTS)) {
|
||||
if (chunk.type === 'thinking') {
|
||||
// Show a "Thinking" step so the UI isn't stuck on the empty indicator
|
||||
if (!isThinking && !fullResponse) {
|
||||
isThinking = true
|
||||
callbacks?.onTextUpdate?.('<step title="Thinking">Analyzing your design request...</step>')
|
||||
}
|
||||
thinkingContent += chunk.content
|
||||
// Stream actual thinking content to UI in real-time
|
||||
callbacks?.onTextUpdate?.(`<step title="Thinking">${thinkingContent}</step>`)
|
||||
} else if (chunk.type === 'text') {
|
||||
isThinking = false
|
||||
fullResponse += chunk.content
|
||||
if (callbacks?.onTextUpdate) {
|
||||
callbacks.onTextUpdate(fullResponse)
|
||||
}
|
||||
// Prepend thinking step so it stays visible after text starts
|
||||
const thinkingPrefix = thinkingContent
|
||||
? `<step title="Thinking">${thinkingContent}</step>\n`
|
||||
: ''
|
||||
callbacks?.onTextUpdate?.(thinkingPrefix + fullResponse)
|
||||
|
||||
if (callbacks?.onApplyPartial) {
|
||||
const allBlocks = extractAllJsonBlocks(fullResponse)
|
||||
if (allBlocks.length > processedBlockCount) {
|
||||
const newBlocks = allBlocks.slice(processedBlockCount)
|
||||
let applied = 0
|
||||
|
||||
for (const blockJson of newBlocks) {
|
||||
const blockNodes = tryParseNodes(blockJson)
|
||||
if (!blockNodes || blockNodes.length === 0) continue
|
||||
|
||||
if (animated) {
|
||||
// Mark sanitized IDs for animation, then upsert (sync).
|
||||
// Canvas-sync will create objects at opacity 0 and schedule
|
||||
// staggered fade-in via setTimeout — does NOT block the stream.
|
||||
const prepared = sanitizeNodesForUpsert(blockNodes)
|
||||
startNewAnimationBatch()
|
||||
markNodesForAnimation(prepared)
|
||||
applied += upsertPreparedNodes(prepared)
|
||||
} else {
|
||||
applied += upsertNodesToCanvas(blockNodes)
|
||||
}
|
||||
if (animated) {
|
||||
// Element-by-element streaming: extract completed JSON objects
|
||||
// from the JSONL block as they finish generating.
|
||||
const { results, newOffset } = extractStreamingNodes(fullResponse, streamingOffset)
|
||||
if (results.length > 0) {
|
||||
streamingOffset = newOffset
|
||||
for (const { node, parentId } of results) {
|
||||
insertStreamingNode(node, parentId)
|
||||
appliedCount++
|
||||
}
|
||||
|
||||
if (applied > 0) {
|
||||
callbacks.onApplyPartial(applied)
|
||||
}
|
||||
processedBlockCount = allBlocks.length
|
||||
callbacks?.onApplyPartial?.(appliedCount)
|
||||
}
|
||||
}
|
||||
} else if (chunk.type === 'error') {
|
||||
|
|
@ -267,8 +397,13 @@ export async function generateDesign(
|
|||
}
|
||||
}
|
||||
|
||||
// Build final tree from response for return value
|
||||
const streamedNodes = extractJsonFromResponse(fullResponse)
|
||||
if (streamedNodes && streamedNodes.length > 0) {
|
||||
// If nothing was applied during streaming, apply now as fallback
|
||||
if (appliedCount === 0) {
|
||||
return { nodes: streamedNodes, rawResponse: fullResponse }
|
||||
}
|
||||
return { nodes: streamedNodes, rawResponse: fullResponse }
|
||||
}
|
||||
|
||||
|
|
@ -276,10 +411,6 @@ export async function generateDesign(
|
|||
throw new Error(streamError)
|
||||
}
|
||||
|
||||
// If no JSON found, return empty nodes but valid response.
|
||||
// This allows the "chatty" response ("I'll create a plan...") to be shown to the user
|
||||
// instead of an ugly "Failed to parse" error.
|
||||
// The UI will just show the text and appliedCount will be 0.
|
||||
return { nodes: [], rawResponse: fullResponse }
|
||||
}
|
||||
|
||||
|
|
@ -525,17 +656,18 @@ function mergeNodeForProgressiveUpsert(
|
|||
}
|
||||
|
||||
const existingById = new Map(existingChildren.map((c) => [c.id, c] as const))
|
||||
const incomingIds = new Set(incomingChildren.map((c) => c.id))
|
||||
const incomingById = new Map(incomingChildren.map((c) => [c.id, c] as const))
|
||||
const mergedChildren: PenNode[] = []
|
||||
|
||||
for (const child of incomingChildren) {
|
||||
const ex = existingById.get(child.id)
|
||||
mergedChildren.push(ex ? mergeNodeForProgressiveUpsert(ex, child) : child)
|
||||
// 1. Existing children first (preserves already-built order)
|
||||
for (const ex of existingChildren) {
|
||||
const inc = incomingById.get(ex.id)
|
||||
mergedChildren.push(inc ? mergeNodeForProgressiveUpsert(ex, inc) : ex)
|
||||
}
|
||||
|
||||
// Keep existing children that are not mentioned in this phase.
|
||||
for (const ex of existingChildren) {
|
||||
if (!incomingIds.has(ex.id)) mergedChildren.push(ex)
|
||||
// 2. Append new incoming children (progressive sections added at end)
|
||||
for (const child of incomingChildren) {
|
||||
if (!existingById.has(child.id)) mergedChildren.push(child)
|
||||
}
|
||||
|
||||
setNodeChildren(merged, mergedChildren)
|
||||
|
|
|
|||
|
|
@ -194,8 +194,8 @@ function insertNodeInTree(
|
|||
}
|
||||
|
||||
return nodes.map((n) => {
|
||||
if (n.id === parentId && 'children' in n) {
|
||||
const children = [...(n.children ?? [])]
|
||||
if (n.id === parentId) {
|
||||
const children = 'children' in n && n.children ? [...n.children] : []
|
||||
if (index !== undefined) {
|
||||
children.splice(index, 0, node)
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue