diff --git a/.github/workflows/build-electron.yml b/.github/workflows/build-electron.yml index f7269336..2f1cab77 100644 --- a/.github/workflows/build-electron.yml +++ b/.github/workflows/build-electron.yml @@ -63,6 +63,12 @@ jobs: - name: Compile MCP server run: bun run mcp:compile + - name: Checkout openpencil-skill + uses: actions/checkout@v4 + with: + repository: zseven-w/openpencil-skill + path: ../openpencil-skill + - name: Compile CLI run: bun run cli:compile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acb4124..516e695f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Format check run: bun run format:check + - name: Bundle CLI skill + run: bun run cli:bundle-skill + - name: Type check run: npx tsc --noEmit diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 5cf0e86e..83b6aad9 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -61,6 +61,12 @@ jobs: fi done + - name: Checkout openpencil-skill + uses: actions/checkout@v4 + with: + repository: zseven-w/openpencil-skill + path: ../openpencil-skill + - name: Compile CLI run: bun run cli:compile @@ -92,6 +98,18 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish pen-engine + run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-engine@${{ steps.version.outputs.version }}" version + working-directory: packages/pen-engine + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish pen-react + run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-react@${{ steps.version.outputs.version }}" version + working-directory: packages/pen-react + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish pen-mcp run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-mcp@${{ steps.version.outputs.version }}" version working-directory: packages/pen-mcp diff --git a/.gitignore b/.gitignore index 15a4a7a8..c85a1496 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ docs/ # Build outputs out/ dist/ +apps/cli/src/commands/skill-bundle.json dist-ssr/ electron-dist/ dist-electron/ diff --git a/CLAUDE.md b/CLAUDE.md index f5829c57..ecb8cc7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,22 @@ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -Detailed module docs are in `packages/CLAUDE.md`, `apps/web/CLAUDE.md`, `apps/desktop/CLAUDE.md`, and `apps/cli/CLAUDE.md` — loaded automatically when working in those directories. +Detailed module docs are loaded automatically when working in subdirectories: + +- **`packages/CLAUDE.md`** — Package overview (all packages at a glance) +- **`packages/pen-types/CLAUDE.md`** — Type definitions for PenDocument model +- **`packages/pen-core/CLAUDE.md`** — Document tree ops, layout engine, variables, boolean ops +- **`packages/pen-engine/CLAUDE.md`** — Headless design engine (document, selection, history, viewport) +- **`packages/pen-react/CLAUDE.md`** — React UI SDK (provider, hooks, panels, canvas) +- **`packages/pen-figma/CLAUDE.md`** — Figma .fig file parser and converter +- **`packages/pen-renderer/CLAUDE.md`** — Standalone CanvasKit/Skia renderer +- **`packages/pen-mcp/CLAUDE.md`** — MCP server (tools, routes, document manager) +- **`packages/pen-ai-skills/CLAUDE.md`** — AI prompt skill engine (phase-driven loading) +- **`packages/pen-sdk/CLAUDE.md`** — Umbrella SDK (re-exports all packages) +- **`packages/agent-native/CLAUDE.md`** — Zig agent runtime (NAPI addon) +- **`apps/web/CLAUDE.md`** — Web app (canvas engine, stores, components, AI services) +- **`apps/desktop/CLAUDE.md`** — Electron desktop app (IPC, file association, auto-updater) +- **`apps/cli/CLAUDE.md`** — CLI tool (`op` commands, input methods, connection) ## Commands diff --git a/apps/cli/CLAUDE.md b/apps/cli/CLAUDE.md index 3361eadc..660f89d8 100644 --- a/apps/cli/CLAUDE.md +++ b/apps/cli/CLAUDE.md @@ -17,6 +17,7 @@ apps/cli/ │ ├── document.ts open, save, get, selection │ ├── export.ts export (react, html, vue, svelte, flutter, swiftui, compose, rn, css) │ ├── import.ts import:svg, import:figma +│ ├── install.ts install, uninstall (openpencil-skill for AI agents) │ ├── layout.ts layout, find-space │ ├── nodes.ts insert, update, delete, move, copy, replace │ ├── pages.ts page list/add/remove/rename/reorder/duplicate @@ -36,6 +37,7 @@ apps/cli/ - **Input methods:** Commands accepting JSON/DSL support inline string, `@filepath`, or `-` (stdin) - **Connection:** WebSocket to running app instance (desktop or web server) - **Launcher:** Auto-detects installed desktop app paths per platform (macOS, Windows, Linux) +- **Skill bundle:** `bun run cli:bundle-skill` pre-generates `skill-bundle.json` from `../openpencil-skill/`, embedded into the binary by esbuild. Falls back to git clone at runtime if the bundle is empty. - **esbuild:** Compiles with `--alias:@=src` to resolve web app imports, `--external:canvas --external:paper` - **Output:** All commands output JSON; `--pretty` flag for human-readable formatting - **Global flags:** `--file ` (target .op file), `--page ` (target page) diff --git a/apps/cli/package.json b/apps/cli/package.json index a4aec6fa..9a888720 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,12 +1,21 @@ { "name": "@zseven-w/openpencil", - "version": "0.7.0", + "version": "0.7.1", "description": "CLI for OpenPencil — control the design tool from your terminal", + "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/apps/cli", + "bugs": { + "url": "https://github.com/ZSeven-W/openpencil/issues" + }, "license": "MIT", "author": { "name": "ZSeven-W", "email": "xkayshen@gmail.com" }, + "repository": { + "type": "git", + "url": "https://github.com/ZSeven-W/openpencil.git", + "directory": "apps/cli" + }, "bin": { "op": "dist/openpencil-cli.cjs" }, diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts new file mode 100644 index 00000000..9c6b7cc0 --- /dev/null +++ b/apps/cli/src/commands/install.ts @@ -0,0 +1,372 @@ +/** + * `op install` — install openpencil-skill for AI coding agents. + * + * The skill files are embedded at build time (via skill-bundle.json). + * If the bundle is empty (e.g. dev build without the skill repo), falls back to git clone. + * + * Auto-detects installed agents (Claude Code, Codex, Cursor, Gemini CLI, OpenCode) + * and installs the skill for each, or use `--target ` to install for one. + */ + +import { execSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + symlinkSync, + readFileSync, + writeFileSync, + rmSync, + unlinkSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import bundle from './skill-bundle.json'; + +const REPO = 'zseven-w/openpencil-skill'; +const REPO_URL = `https://github.com/${REPO}.git`; +const SKILL_NAME = 'openpencil-skill'; + +type Target = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'; + +const ALL_TARGETS: Target[] = ['claude', 'codex', 'cursor', 'gemini', 'opencode']; + +const hasBundledFiles = Object.keys(bundle.files).length > 0; + +// --- Helpers --- + +function which(cmd: string): string | null { + try { + return execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf-8' }).trim() || null; + } catch { + return null; + } +} + +function detectTargets(): Target[] { + const found: Target[] = []; + if (which('claude')) found.push('claude'); + if (which('codex')) found.push('codex'); + if (existsSync(join(homedir(), '.cursor'))) found.push('cursor'); + if (which('gemini')) found.push('gemini'); + if (which('opencode')) found.push('opencode'); + return found; +} + +function log(msg: string): void { + process.stderr.write(msg + '\n'); +} +function logOk(target: string, msg: string): void { + log(` ✓ ${target}: ${msg}`); +} +function logSkip(target: string, msg: string): void { + log(` - ${target}: ${msg}`); +} +function logErr(target: string, msg: string): void { + log(` ✗ ${target}: ${msg}`); +} + +/** Write bundled files to a destination directory. */ +function writeBundleTo(dest: string, fileFilter?: (relativePath: string) => boolean): void { + const files = bundle.files as Record; + for (const [relativePath, content] of Object.entries(files)) { + if (fileFilter && !fileFilter(relativePath)) continue; + const fullPath = join(dest, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, content); + } +} + +/** Git clone/update (fallback when no bundle). */ +function ensureRepo(dest: string): void { + if (existsSync(join(dest, '.git'))) { + execSync('git pull --ff-only 2>/dev/null', { cwd: dest, stdio: 'ignore' }); + } else { + mkdirSync(dirname(dest), { recursive: true }); + execSync(`git clone --depth 1 ${REPO_URL} "${dest}"`, { stdio: 'ignore' }); + } +} + +/** Write bundled files or fall back to git clone. */ +function ensureSkillDir(dest: string, fileFilter?: (p: string) => boolean): void { + if (hasBundledFiles) { + mkdirSync(dest, { recursive: true }); + writeBundleTo(dest, fileFilter); + } else { + ensureRepo(dest); + } +} + +// --- Installers --- + +function installClaude(): void { + const target = 'Claude Code'; + try { + if (hasBundledFiles) { + // Write directly to the plugin cache — no GitHub access needed + const cacheDir = join( + homedir(), + '.claude', + 'plugins', + 'cache', + SKILL_NAME, + SKILL_NAME, + bundle.version, + ); + mkdirSync(cacheDir, { recursive: true }); + writeBundleTo(cacheDir); + + // Update installed_plugins.json + const registryPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json'); + const registry = existsSync(registryPath) + ? JSON.parse(readFileSync(registryPath, 'utf-8')) + : { version: 2, plugins: {} }; + const key = `${SKILL_NAME}@${SKILL_NAME}`; + const now = new Date().toISOString(); + registry.plugins[key] = [ + { + scope: 'user', + installPath: cacheDir, + version: bundle.version, + installedAt: registry.plugins[key]?.[0]?.installedAt ?? now, + lastUpdated: now, + }, + ]; + writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n'); + + // Register marketplace entry + const marketplacesPath = join(homedir(), '.claude', 'plugins', 'known_marketplaces.json'); + const marketplaces = existsSync(marketplacesPath) + ? JSON.parse(readFileSync(marketplacesPath, 'utf-8')) + : {}; + if (!marketplaces[SKILL_NAME]) { + marketplaces[SKILL_NAME] = { + source: { source: 'github', repo: REPO }, + installLocation: join(homedir(), '.claude', 'plugins', 'marketplaces', SKILL_NAME), + lastUpdated: now, + }; + writeFileSync(marketplacesPath, JSON.stringify(marketplaces, null, 2) + '\n'); + } + logOk(target, `installed v${bundle.version} (bundled)`); + } else { + // Fallback: use claude CLI + try { + execSync(`claude plugin marketplace add ${REPO}`, { stdio: 'ignore' }); + } catch { + /* already added */ + } + execSync(`claude plugin install ${SKILL_NAME}@${SKILL_NAME}`, { stdio: 'ignore' }); + logOk(target, 'installed'); + } + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function installCodex(): void { + const target = 'Codex'; + try { + const cloneDir = join(homedir(), '.codex', SKILL_NAME); + ensureSkillDir(cloneDir); + + const skillsDir = join(homedir(), '.agents', 'skills'); + mkdirSync(skillsDir, { recursive: true }); + + const linkPath = join(skillsDir, SKILL_NAME); + const linkTarget = join(cloneDir, 'skills'); + if (!existsSync(linkPath)) { + symlinkSync(linkTarget, linkPath); + } + logOk(target, hasBundledFiles ? `installed v${bundle.version} (bundled)` : 'installed'); + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function installCursor(): void { + const target = 'Cursor'; + try { + const destDir = join(homedir(), '.cursor', 'plugins', SKILL_NAME); + ensureSkillDir(destDir); + logOk(target, hasBundledFiles ? `installed v${bundle.version} (bundled)` : 'installed'); + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function installGemini(): void { + const target = 'Gemini CLI'; + try { + const destDir = join(homedir(), '.gemini', 'extensions', SKILL_NAME); + ensureSkillDir(destDir); + logOk(target, hasBundledFiles ? `installed v${bundle.version} (bundled)` : 'installed'); + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function installOpenCode(): void { + const target = 'OpenCode'; + try { + const configPath = join(homedir(), '.config', 'opencode', 'opencode.json'); + const pluginEntry = `${SKILL_NAME}@git+${REPO_URL}`; + + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const plugins: string[] = config.plugin ?? []; + if (!plugins.some((p: string) => p.includes(SKILL_NAME))) { + plugins.push(pluginEntry); + config.plugin = plugins; + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + logOk(target, 'added to opencode.json'); + } else { + logSkip(target, 'already configured'); + } + } else { + mkdirSync(join(homedir(), '.config', 'opencode'), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ plugin: [pluginEntry] }, null, 2) + '\n'); + logOk(target, 'created opencode.json'); + } + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +const INSTALLERS: Record void> = { + claude: installClaude, + codex: installCodex, + cursor: installCursor, + gemini: installGemini, + opencode: installOpenCode, +}; + +// --- Uninstallers --- + +function uninstallClaude(): void { + const target = 'Claude Code'; + try { + if (hasBundledFiles) { + // Remove from plugin cache + const cacheParent = join(homedir(), '.claude', 'plugins', 'cache', SKILL_NAME); + if (existsSync(cacheParent)) rmSync(cacheParent, { recursive: true }); + + // Remove from installed_plugins.json + const registryPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json'); + if (existsSync(registryPath)) { + const registry = JSON.parse(readFileSync(registryPath, 'utf-8')); + delete registry.plugins[`${SKILL_NAME}@${SKILL_NAME}`]; + writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n'); + } + logOk(target, 'uninstalled'); + } else { + execSync(`claude plugin uninstall ${SKILL_NAME}@${SKILL_NAME}`, { stdio: 'ignore' }); + logOk(target, 'uninstalled'); + } + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function uninstallCodex(): void { + const target = 'Codex'; + try { + const linkPath = join(homedir(), '.agents', 'skills', SKILL_NAME); + if (existsSync(linkPath)) unlinkSync(linkPath); + const cloneDir = join(homedir(), '.codex', SKILL_NAME); + if (existsSync(cloneDir)) rmSync(cloneDir, { recursive: true }); + logOk(target, 'uninstalled'); + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function uninstallCursor(): void { + const target = 'Cursor'; + try { + const dir = join(homedir(), '.cursor', 'plugins', SKILL_NAME); + if (existsSync(dir)) rmSync(dir, { recursive: true }); + logOk(target, 'uninstalled'); + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function uninstallGemini(): void { + const target = 'Gemini CLI'; + try { + const dir = join(homedir(), '.gemini', 'extensions', SKILL_NAME); + if (existsSync(dir)) rmSync(dir, { recursive: true }); + logOk(target, 'uninstalled'); + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +function uninstallOpenCode(): void { + const target = 'OpenCode'; + try { + const configPath = join(homedir(), '.config', 'opencode', 'opencode.json'); + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const plugins: string[] = config.plugin ?? []; + config.plugin = plugins.filter((p: string) => !p.includes(SKILL_NAME)); + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + logOk(target, 'removed from opencode.json'); + } else { + logSkip(target, 'not configured'); + } + } catch (e) { + logErr(target, e instanceof Error ? e.message : String(e)); + } +} + +const UNINSTALLERS: Record void> = { + claude: uninstallClaude, + codex: uninstallCodex, + cursor: uninstallCursor, + gemini: uninstallGemini, + opencode: uninstallOpenCode, +}; + +// --- Public commands --- + +export interface InstallFlags { + target?: string; +} + +function resolveTargets(targetFlag: string | undefined, mode: 'install' | 'uninstall'): Target[] { + if (targetFlag) { + const t = targetFlag.toLowerCase() as Target; + if (!ALL_TARGETS.includes(t)) { + log(`Unknown target: "${targetFlag}". Available: ${ALL_TARGETS.join(', ')}`); + process.exit(1); + } + return [t]; + } + const detected = detectTargets(); + if (detected.length === 0 && mode === 'install') { + log('No supported AI coding agents detected.'); + log(`Supported: ${ALL_TARGETS.join(', ')}`); + log('Use --target to install for a specific agent.'); + process.exit(1); + } + return detected; +} + +export function cmdInstall(flags: InstallFlags): void { + const targets = resolveTargets(flags.target, 'install'); + log(`Installing ${SKILL_NAME} for: ${targets.join(', ')}`); + if (!hasBundledFiles) log('(no embedded bundle — using git clone fallback)'); + log(''); + for (const t of targets) INSTALLERS[t](); + log(''); + log('Done. Restart your agent to load the skill.'); +} + +export function cmdUninstall(flags: InstallFlags): void { + const targets = resolveTargets(flags.target, 'uninstall'); + log(`Uninstalling ${SKILL_NAME} from: ${targets.join(', ')}`); + log(''); + for (const t of targets) UNINSTALLERS[t](); + log(''); + log('Done.'); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 27526db4..181d6355 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -101,6 +101,11 @@ Import: op import:svg Import SVG file op import:figma Import Figma file +Skill: + op install [--target T] Install openpencil-skill for AI agents + op uninstall [--target T] Uninstall openpencil-skill + Targets: claude, codex, cursor, gemini, opencode (auto-detected if omitted) + Layout: op layout [--parent P] [--depth N] op find-space [--direction right|bottom|left|top] @@ -407,6 +412,18 @@ async function main(): Promise { break; } + // --- Skill install --- + case 'install': { + const { cmdInstall } = await import('./commands/install'); + cmdInstall({ target: flags.target as string | undefined }); + break; + } + case 'uninstall': { + const { cmdUninstall } = await import('./commands/install'); + cmdUninstall({ target: flags.target as string | undefined }); + break; + } + // --- Layout --- case 'layout': { const { cmdLayout } = await import('./commands/layout'); diff --git a/apps/desktop/dev.ts b/apps/desktop/dev.ts index 1fa6219c..d83960af 100644 --- a/apps/desktop/dev.ts +++ b/apps/desktop/dev.ts @@ -9,14 +9,12 @@ import { spawn, execSync, type ChildProcess } from 'node:child_process'; import { build } from 'esbuild'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { Socket } from 'node:net'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills'; -import { - getDevServerConflictMessage, - getElectronBinaryPath, - getElectronSpawnEnv, -} from './dev-utils'; +import { getElectronBinaryPath, getElectronSpawnEnv } from './dev-utils'; const DESKTOP_DIR = import.meta.dirname; const ROOT = join(DESKTOP_DIR, '..', '..'); @@ -38,55 +36,43 @@ const GENERATED_SKILL_REGISTRY = join( async function waitForViteServer( baseUrl: string, vite: ChildProcess, - port: number, timeoutMs = 30_000, ): Promise { + const target = new URL(baseUrl); + const port = Number.parseInt(target.port || '80', 10); + const hosts = + target.hostname === 'localhost' ? ['127.0.0.1', '::1', 'localhost'] : [target.hostname]; const start = Date.now(); let viteExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { viteExit = { code, signal }; }; + async function canConnect(host: string): Promise { + return await new Promise((resolve) => { + const socket = new Socket(); + + const finish = (ok: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(ok); + }; + + socket.setTimeout(800); + socket.once('connect', () => finish(true)); + socket.once('timeout', () => finish(false)); + socket.once('error', () => finish(false)); + socket.connect(port, host); + }); + } + vite.once('exit', handleExit); while (Date.now() - start < timeoutMs) { - let baseReachable = false; - let viteClientReachable = false; - let viteClientStatus: number | null = null; - - try { - const res = await fetch(baseUrl, { - signal: AbortSignal.timeout(500), - }); - baseReachable = res.ok || res.status < 500; - } catch { - // server not ready yet - } - - try { - const res = await fetch(`${baseUrl}/@vite/client`, { - signal: AbortSignal.timeout(500), - }); - viteClientStatus = res.status; - viteClientReachable = res.ok; - if (viteClientReachable) { + for (const host of hosts) { + if (await canConnect(host)) { vite.off('exit', handleExit); return; } - } catch { - // Vite client not ready yet. - } - - const conflict = getDevServerConflictMessage( - { - baseReachable, - viteClientReachable, - viteClientStatus, - }, - port, - ); - if (conflict) { - vite.off('exit', handleExit); - throw new Error(conflict); } if (viteExit) { @@ -159,18 +145,47 @@ async function main(): Promise { vite.kill(); }; + /** Kill the detached MCP server spawned by Nitro (survives Vite teardown). */ + const stopMcpServer = () => { + const pidFile = join(tmpdir(), 'openpencil-mcp-server.pid'); + const portFile = join(tmpdir(), 'openpencil-mcp-server.port'); + try { + if (existsSync(pidFile)) { + const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10); + if (Number.isFinite(pid)) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* already gone */ + } + } + } + } catch { + /* ignore */ + } + for (const f of [pidFile, portFile]) { + try { + unlinkSync(f); + } catch { + /* ignore */ + } + } + }; + // Ensure cleanup on exit const cleanup = () => { stopVite(); + stopMcpServer(); process.exit(); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); + process.on('exit', stopMcpServer); // 2. Wait for Vite to be ready console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`); try { - await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite, VITE_DEV_PORT); + await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite); } catch (error) { stopVite(); throw error; diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index f5fa1b32..7221398d 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -21,6 +21,12 @@ extraResources: to: mcp-server.cjs - from: apps/cli/dist/openpencil-cli.cjs to: openpencil-cli.cjs + # agent-native is external in Vite/Nitro build — ship it alongside server/ so + # Node can resolve `@zseven-w/agent-native` at runtime. The `napi/` directory + # IS the package root (contains package.json with name "@zseven-w/agent-native" + # and agent_napi.node copied by agent-native:bundle script). + - from: packages/agent-native/napi + to: server/node_modules/@zseven-w/agent-native mac: category: public.app-category.graphics-design diff --git a/apps/desktop/main.ts b/apps/desktop/main.ts index dbafc94e..38d417ee 100644 --- a/apps/desktop/main.ts +++ b/apps/desktop/main.ts @@ -9,7 +9,8 @@ import { execSync } from 'node:child_process'; import { fork, type ChildProcess } from 'node:child_process'; import { createServer } from 'node:net'; import { join, extname } from 'node:path'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'; import { buildAppMenu } from './app-menu'; @@ -691,9 +692,39 @@ app.on('activate', () => { app.on('before-quit', () => { clearUpdateTimer(); killNitroProcess(); + killMcpServer(); cleanupPortFile().catch(() => {}); }); +/** Kill the detached MCP server child (spawned by Nitro via mcp-server-manager). */ +function killMcpServer(): void { + // PID file path matches apps/web/server/utils/mcp-server-manager.ts + const pidFile = join(tmpdir(), 'openpencil-mcp-server.pid'); + const portFile = join(tmpdir(), 'openpencil-mcp-server.port'); + try { + if (!existsSync(pidFile)) return; + const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10); + if (!Number.isFinite(pid)) return; + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* already dead */ + } + } catch { + /* ignore */ + } + try { + unlinkSync(pidFile); + } catch { + /* ignore */ + } + try { + unlinkSync(portFile); + } catch { + /* ignore */ + } +} + /** Platform-aware Nitro process termination. */ function killNitroProcess(): void { if (!nitroProcess) return; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a4c97eb4..5e256fbf 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/desktop", - "version": "0.7.0", + "version": "0.7.1", "private": true, "type": "module" } diff --git a/apps/desktop/preload.ts b/apps/desktop/preload.ts index 73cf944c..efd21d84 100644 --- a/apps/desktop/preload.ts +++ b/apps/desktop/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; +import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron'; export type UpdaterStatus = | 'disabled' @@ -267,6 +267,8 @@ export interface ElectronAPI { cancelLabel: string; }) => Promise<'save' | 'discard' | 'cancel'>; syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void; + /** Resolve the absolute filesystem path of a File object obtained from drag-and-drop. */ + getPathForFile: (file: File) => string | null; updater: { getState: () => Promise; checkForUpdates: () => Promise; @@ -329,6 +331,15 @@ const api: ElectronAPI = { syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => ipcRenderer.send('recent-files:sync', files), + getPathForFile: (file: File) => { + try { + const p = webUtils.getPathForFile(file); + return p && p.length > 0 ? p : null; + } catch { + return null; + } + }, + confirmClose: () => ipcRenderer.send('window:confirmClose'), confirmUnsavedChanges: (payload) => ipcRenderer.invoke('dialog:confirmUnsavedChanges', payload), diff --git a/apps/web/package.json b/apps/web/package.json index b9401db9..fa88d86a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,10 +1,11 @@ { "name": "@zseven-w/web", - "version": "0.7.0", + "version": "0.7.1", "private": true, "type": "module", "dependencies": { "@zseven-w/agent-native": "workspace:*", + "@zseven-w/pen-acp": "workspace:*", "@zseven-w/pen-ai-skills": "workspace:*", "@zseven-w/pen-core": "workspace:*", "@zseven-w/pen-engine": "workspace:*", diff --git a/apps/web/server/__tests__/mcp-sync-state-active.test.ts b/apps/web/server/__tests__/mcp-sync-state-active.test.ts index 6c62e2ca..701af36a 100644 --- a/apps/web/server/__tests__/mcp-sync-state-active.test.ts +++ b/apps/web/server/__tests__/mcp-sync-state-active.test.ts @@ -60,4 +60,23 @@ describe('mcp-sync-state: active client tracking', () => { it('sendToClient returns false for unknown client', () => { expect(sendToClient('nope', { type: 'x' })).toBe(false); }); + + it('broadcasts document updates to other clients and prunes broken writers', () => { + const received: string[] = []; + registerSSEClient('client-source', { push: () => {} }); + registerSSEClient('client-target', { push: (data: string) => received.push(data) }); + registerSSEClient('client-broken', { + push: () => { + throw new Error('writer closed'); + }, + }); + + setSyncDocument({ version: '1.0.0', children: [] } as PenDocument, 'client-source'); + + expect(received).toHaveLength(1); + expect(JSON.parse(received[0]).type).toBe('document:update'); + expect(isClientConnected('client-source')).toBe(true); + expect(isClientConnected('client-target')).toBe(true); + expect(isClientConnected('client-broken')).toBe(false); + }); }); diff --git a/apps/web/server/api/ai/agent.ts b/apps/web/server/api/ai/agent.ts index 2d4adcb7..ebe45786 100644 --- a/apps/web/server/api/ai/agent.ts +++ b/apps/web/server/api/ai/agent.ts @@ -27,8 +27,10 @@ import { cleanup, abortSession, createSession, + createAcpSession, touchSession, type AgentSession, + type NativeAgentSession, } from '../../utils/agent-sessions'; import { shouldShortCircuitPlanLayout, @@ -41,6 +43,9 @@ import { requireOpenAICompatBaseURL, } from './provider-url'; import { startSSEKeepAlive } from '../../utils/sse-keepalive'; +import { getAcpConnection } from '../../utils/acp-connection-manager'; +import { getMcpServerStatus } from '../../utils/mcp-server-manager'; +import { acpUpdateToSSE } from '@zseven-w/pen-acp'; const TOOL_LEVEL_MAP: Record = { batch_get: 'read', @@ -84,7 +89,12 @@ const ROLE_SKILL_PHASE: Record = { const ROLE_TOOL_INSTRUCTIONS: Record = { designer: `You are a design team member. When asked to create designs, you MUST call the generate_design tool with a descriptive prompt. You can also use insert_node for manual node creation, batch_get and snapshot_layout to inspect the canvas, and find_empty_space to find placement locations. Always end with a short natural-language summary of what you created or changed. Never stop at tool calls only.`, reviewer: `You are a design reviewer. Use batch_get and snapshot_layout to inspect the current canvas state. Use get_selection to see what the user has selected. Provide detailed feedback on layout, spacing, typography, and visual hierarchy. Always end with a short natural-language summary for the lead agent.`, - editor: `You are a design editor. Use batch_get and snapshot_layout to understand the current canvas. Use update_node to modify node properties, delete_node to remove elements, and insert_node to add new elements. Use find_empty_space to find placement locations. Always end with a short natural-language summary of what changed. Never stop at tool calls only.`, + editor: `You are a design editor. ALWAYS start by calling batch_get or snapshot_layout to understand the current canvas state before making changes. Match your action to user intent: +- To READ/INSPECT: use batch_get (search nodes) or snapshot_layout (spatial overview) +- To DELETE/REMOVE: use batch_get to find the node ID, then delete_node to remove it — do NOT create new nodes +- To MODIFY: use update_node to change properties of existing nodes +- To ADD: use insert_node to add new elements, find_empty_space for placement +Always end with a short natural-language summary of what changed. Never stop at tool calls only.`, researcher: `You are a design researcher. Use batch_get and snapshot_layout to analyze the current canvas state. Use find_empty_space to identify available space. Use get_selection to see what the user has selected. Provide analysis and recommendations. Always end with a short natural-language summary for the lead agent.`, }; @@ -165,7 +175,7 @@ interface AgentBody { sessionId: string; messages: Array<{ role: string; content: string }>; systemPrompt: string; - providerType: 'anthropic' | 'openai-compat'; + providerType: 'anthropic' | 'openai-compat' | 'acp'; apiKey: string; model: string; baseURL?: string; @@ -178,6 +188,8 @@ interface AgentBody { concurrency?: number; designMdContent?: string; hasVariables?: boolean; + acpAgentId?: string; + acpConfig?: import('../../../src/types/agent-settings').AcpAgentConfig; } /** Map Zig event JSON to client SSE format. @@ -239,9 +251,28 @@ function zigEventToSSE(raw: string): string { break; case 'result': if (data.is_error) { + const parts: string[] = [`Agent error: ${data.subtype ?? 'unknown'}`]; + if (data.result) parts.push(String(data.result)); + // Provider-captured upstream errors (HTTP body from anthropic / + // openai_compat). Often a JSON envelope — show the inner message + // when we can parse it; otherwise dump the raw body. + if (Array.isArray(data.errors)) { + for (const raw of data.errors as unknown[]) { + const text = typeof raw === 'string' ? raw : JSON.stringify(raw); + let pretty = text; + try { + const obj = JSON.parse(text); + const inner = obj?.error?.message ?? obj?.message ?? obj?.error?.type; + if (typeof inner === 'string' && inner.length > 0) pretty = inner; + } catch { + /* not JSON — keep raw */ + } + parts.push(pretty.length > 600 ? pretty.slice(0, 600) + '…' : pretty); + } + } mapped = { type: 'error', - message: `Agent error: ${data.subtype ?? 'unknown'}${data.result ? ' — ' + data.result : ''}`, + message: parts.join(' — '), fatal: true, }; } else { @@ -268,9 +299,10 @@ function zigEventToSSE(raw: string): string { return `event: ${mapped.type}\ndata: ${JSON.stringify(mapped)}\n\n`; } -/** Run a delegated member asynchronously — does NOT block the caller. */ +/** Run a delegated member asynchronously — does NOT block the caller. + * Only called for native agent sessions (team mode). */ async function runDelegateMember( - session: AgentSession, + session: NativeAgentSession, body: AgentBody, controller: ReadableStreamDefaultController, encoder: TextEncoder, @@ -400,6 +432,14 @@ export default defineEventHandler(async (event) => { const toolName = session.toolNames.get(body.toolCallId); updateLayoutSessionState(session, toolName, body.result); + // ACP sessions: tools are executed by the agent via MCP, not client-side. + // Just acknowledge the result and return. + if (session.type === 'acp') { + session.lastActivity = Date.now(); + session.toolNames.delete(body.toolCallId); + return { ok: true }; + } + const resultJson = JSON.stringify(body.result); // Per-toolCallId routing: check if this tool belongs to a member const memberId = session.toolOwners?.get(body.toolCallId); @@ -436,6 +476,211 @@ export default defineEventHandler(async (event) => { // ── Start agent loop (SSE stream) ────────────────────────── const body = await readBody(event); + + // ── ACP Agent path ────────────────────────────────────────── + if (body?.providerType === 'acp' && body.acpAgentId) { + let conn = getAcpConnection(body.acpAgentId as string); + // If connection missing (e.g. dev server restart) but we have the config, + // attempt to reconnect transparently using the config sent by the client. + if (!conn && (body as any).acpConfig) { + console.log(`[acp] connection missing, auto-reconnecting ${body.acpAgentId}`); + const { connectAcp } = await import('../../utils/acp-connection-manager'); + const result = await connectAcp(body.acpAgentId as string, (body as any).acpConfig); + if (!result.connected) { + throw createError({ + statusCode: 400, + message: `ACP agent auto-reconnect failed: ${result.error ?? 'unknown error'}`, + }); + } + conn = getAcpConnection(body.acpAgentId as string); + } + if (!conn) { + throw createError({ statusCode: 400, message: 'ACP agent not connected' }); + } + + // Create ACP session. ACP agents need the OpenPencil MCP server to do + // anything useful (without it they just call Terminal/Skill tools that + // don't work here). Require it to be running — the user starts it from + // the MCP settings tab. + // NOTE: claude-agent-acp expects `type: 'http' | 'sse'` (not `transport`). + const mcpStatus = getMcpServerStatus(); + if (!mcpStatus.running || !mcpStatus.port) { + throw createError({ + statusCode: 400, + message: + 'MCP server is not running. Open Settings → MCP and click "Start" to enable ACP agents to access OpenPencil design tools.', + }); + } + const mcpServers = [ + { + name: 'openpencil', + type: 'http' as const, + url: `http://127.0.0.1:${mcpStatus.port}/mcp`, + headers: [] as Array<{ name: string; value: string }>, + }, + ]; + console.log( + `[acp] newSession mcpServers=${JSON.stringify(mcpServers)} (mcpStatus: running=${mcpStatus.running}, port=${mcpStatus.port})`, + ); + // Override the agent's default system prompt (via _meta) to prevent it + // from using the openpencil-skill (which is designed for CLI scenarios + // where the agent runs `op` commands in terminals). Inside OpenPencil, + // the agent should use MCP tools directly. + const acpSystemPrompt = [ + 'You are an AI design assistant integrated inside the OpenPencil vector design tool.', + 'The user sees a live canvas; your job is to produce polished, visually refined UI designs on it.', + 'You have direct access to OpenPencil\'s document via the "openpencil" MCP server.', + '', + '## Tool Usage Rules', + '- NEVER use Bash/Terminal to run `op` CLI commands. The CLI is not available here.', + '- NEVER use the openpencil-skill or Skill tool. They are for a different context.', + '- DO use the `mcp__openpencil__*` tools to operate on the canvas.', + '- After finishing, provide a brief one-sentence summary of what was done.', + '', + '## REQUIRED Workflow for Creating New Designs', + 'Always follow this three-phase pipeline (it produces higher quality than ad-hoc insert calls):', + '', + "1. **Load the design guide (ONCE)**: Call `get_design_prompt` to receive OpenPencil's design principles, node schema details, role system, color/typography tokens, and layout patterns. Read it carefully — it defines the canonical shapes and defaults.", + '2. **Build skeleton**: Call `design_skeleton` with a high-level description. This creates the structural frames (sections, layout containers) with correct auto-layout.', + '3. **Fill content**: Call `design_content` once per section from step 2, adding the concrete children (buttons, inputs, text, icons).', + '4. **Refine** (optional): Call `design_refine` on the root to apply final polish (consistent spacing, role-based styling).', + '', + 'Only fall back to `batch_design` or `insert_node` when the user explicitly asks for small/surgical edits rather than a new page.', + '', + '## Modifying Existing Designs', + '- Call `snapshot_layout` first to see the current tree.', + '- Use `update_node` for property changes, `move_node` for reparenting, `delete_node` to remove.', + '- Prefer one `batch_design` over many individual calls when making multiple related changes.', + '', + '## Canonical Node Shapes (IMPORTANT)', + 'The canvas will render nothing useful if you use the wrong `type` or shape. Use these:', + '', + '- **Frame** (container with layout): `{"type": "frame", "name": "X", "width": 375, "height": 812, "layout": "vertical", "gap": 16, "padding": [24, 24, 24, 24], "fill": [{"type": "solid", "color": "#FFFFFF"}], "children": [...]}`', + '- **Text** (field is `content` NOT `text`): `{"type": "text", "name": "Title", "content": "Welcome", "fontSize": 24, "fontWeight": 700, "fill": [{"type": "solid", "color": "#111827"}]}`', + '- **Icon** (use `icon_font` NOT `icon`, field is `iconFontName` NOT `iconName`): `{"type": "icon_font", "name": "Lock Icon", "iconFontName": "lock", "width": 20, "height": 20, "fill": [{"type": "solid", "color": "#6B7280"}]}`. Common iconFontName values (Lucide): `mail`, `lock`, `eye`, `eye-off`, `chrome`, `apple`, `message-circle`, `x`, `arrow-right`, `search`, `heart`, `star`, `check`, `plus`, `bell`, `home`, `user`, `settings`.', + '- **Rectangle**: `{"type": "rectangle", "width": 100, "height": 100, "cornerRadius": 8, "fill": [{"type": "solid", "color": "#3B82F6"}]}`', + '- **Button** (frame + text child): use `"role": "cta-button"` on the frame so role resolution applies standard button styling.', + '', + '## STRICT JSON Rules', + 'When emitting node JSON inside tool arguments, produce strictly valid JSON:', + '- Every property MUST have BOTH a key and value. NEVER emit `": 50` or `: 50` with no key.', + '- Every key MUST be a double-quoted non-empty string.', + '- `fill` is ALWAYS an array: `"fill": [{"type": "solid", "color": "#hex"}]`.', + '- `stroke` is `{"thickness": 1, "fill": [{"type": "solid", "color": "#hex"}]}`. NEVER `{"thickness": 1, "color": "#hex"}`.', + '- NO trailing commas, NO comments, use straight `"` not smart quotes.', + '- Layout on frames: `"layout": "vertical" | "horizontal" | "none"`, `"gap": number`, `"padding": [top, right, bottom, left]`, `"alignItems": "start" | "center" | "end"`, `"justifyContent": "start" | "center" | "end" | "space-between"`.', + '- Width/height: number OR `"fill_container"` OR `"fit_content"`.', + '- Before calling the tool, mentally verify the JSON is valid. Every key has a value; every value has a key.', + ].join('\n'); + + const { sessionId: acpSessionId } = await conn.connection.newSession({ + cwd: process.cwd(), + mcpServers, + _meta: { systemPrompt: acpSystemPrompt }, + } as Parameters[0]); + + const clientSessionId = body.sessionId as string; + agentSessions.set( + clientSessionId, + createAcpSession({ + acpSessionId, + acpAgentId: body.acpAgentId as string, + connection: conn.connection, + }), + ); + + // Build prompt from last user message + const lastMsg = ((body.messages as any[]) ?? []).at(-1); + const promptText = + typeof lastMsg?.content === 'string' + ? lastMsg.content + : JSON.stringify(lastMsg?.content ?? ''); + + // Wire session/update notifications into SSE stream + const updateTarget = new EventTarget(); + conn.sessionUpdateEmitter = updateTarget; + + const encoder = new TextEncoder(); + let streamClosed = false; + const stream = new ReadableStream({ + async start(controller) { + const safeEnqueue = (chunk: Uint8Array) => { + if (streamClosed) return; + try { + controller.enqueue(chunk); + } catch (err) { + // Controller may have closed mid-notification (e.g. client disconnect, + // idle timeout). Mark closed and stop enqueuing to avoid noise. + streamClosed = true; + } + }; + const onUpdate = (e: Event) => { + const notification = (e as CustomEvent).detail; + const sse = acpUpdateToSSE(notification); + if (sse) safeEnqueue(encoder.encode(sse)); + }; + updateTarget.addEventListener('update', onUpdate); + + // Keep-alive: prevent Bun's 10s idle timeout from killing the stream + // during long MCP tool calls (e.g. snapshot_layout → insert_node chain). + const keepAlive = startSSEKeepAlive( + () => safeEnqueue(encoder.encode(`: keepalive\n\n`)), + 5000, + ); + + try { + console.log(`[acp] prompt() start for ${acpSessionId}`); + const promptResult = await conn.connection.prompt({ + sessionId: acpSessionId, + prompt: [{ type: 'text', text: promptText }], + }); + console.log( + `[acp] prompt() returned, stopReason=${(promptResult as { stopReason?: string })?.stopReason ?? 'unknown'}, streamClosed=${streamClosed}`, + ); + + safeEnqueue( + encoder.encode( + `event: done\ndata: ${JSON.stringify({ type: 'done', totalTurns: 1 })}\n\n`, + ), + ); + } catch (err) { + console.error(`[acp] prompt() threw:`, err); + safeEnqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ + type: 'error', + message: `ACP error: ${err instanceof Error ? err.message : String(err)}`, + fatal: true, + })}\n\n`, + ), + ); + } finally { + console.log(`[acp] prompt() finally, closing stream`); + clearInterval(keepAlive); + updateTarget.removeEventListener('update', onUpdate); + conn.sessionUpdateEmitter = null; + agentSessions.delete(clientSessionId); + if (!streamClosed) { + streamClosed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + } + } + }, + }); + + setResponseHeaders(event, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + return new Response(stream); + } + if ( !body?.sessionId || !body.messages || @@ -587,8 +832,9 @@ export default defineEventHandler(async (event) => { } }); + // providerType is narrowed: 'acp' returned early above const provider = createProviderHandle( - body.providerType, + body.providerType as 'anthropic' | 'openai-compat', body.apiKey, body.model, normalizedBaseURL, @@ -776,7 +1022,7 @@ export default defineEventHandler(async (event) => { // Create provider (use member model or lead's) const mProvider = createProviderHandle( - body.providerType, + body.providerType as 'anthropic' | 'openai-compat', body.apiKey, memberModel ?? body.model, normalizedBaseURL, diff --git a/apps/web/server/api/ai/connect-acp.ts b/apps/web/server/api/ai/connect-acp.ts new file mode 100644 index 00000000..7c9f0ef1 --- /dev/null +++ b/apps/web/server/api/ai/connect-acp.ts @@ -0,0 +1,30 @@ +import { defineEventHandler, readBody, createError } from 'h3'; +import { connectAcp, disconnectAcp } from '../../utils/acp-connection-manager'; +import type { AcpAgentConfig } from '../../../src/types/agent-settings'; + +export default defineEventHandler(async (event) => { + const body = await readBody<{ + action: 'connect' | 'disconnect'; + agentId: string; + config?: AcpAgentConfig; + }>(event); + + if (!body?.agentId || !body.action) { + throw createError({ statusCode: 400, message: 'Missing: agentId, action' }); + } + + if (body.action === 'connect') { + if (!body.config) { + throw createError({ statusCode: 400, message: 'Missing: config (required for connect)' }); + } + const result = await connectAcp(body.agentId, body.config); + return result; + } + + if (body.action === 'disconnect') { + await disconnectAcp(body.agentId); + return { connected: false }; + } + + throw createError({ statusCode: 400, message: `Unknown action: ${body.action}` }); +}); diff --git a/apps/web/server/api/mcp/document.post.ts b/apps/web/server/api/mcp/document.post.ts index 2795d6ed..06066e66 100644 --- a/apps/web/server/api/mcp/document.post.ts +++ b/apps/web/server/api/mcp/document.post.ts @@ -1,5 +1,6 @@ -import { defineEventHandler, readBody, createError } from 'h3'; +import { defineEventHandler, readBody, createError, getRequestHeader, setResponseStatus } from 'h3'; import { setSyncDocument } from '../../utils/mcp-sync-state'; +import { serverLog } from '../../utils/server-logger'; import type { PenDocument } from '../../../src/types/pen'; interface PostBody { @@ -7,16 +8,138 @@ interface PostBody { sourceClientId?: string; } +interface DocumentStats { + nodeCount: number; + imageCount: number; + dataUrlImageCount: number; + dataUrlChars: number; +} + +function collectDocumentStats(doc: PenDocument): DocumentStats { + const stats: DocumentStats = { + nodeCount: 0, + imageCount: 0, + dataUrlImageCount: 0, + dataUrlChars: 0, + }; + + const visit = (nodes?: unknown): void => { + if (!Array.isArray(nodes)) return; + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + stats.nodeCount++; + + const typedNode = node as { + type?: string; + src?: string; + children?: unknown; + }; + + if (typedNode.type === 'image') { + stats.imageCount++; + if (typeof typedNode.src === 'string' && typedNode.src.startsWith('data:')) { + stats.dataUrlImageCount++; + stats.dataUrlChars += typedNode.src.length; + } + } + + visit(typedNode.children); + } + }; + + visit(doc.children); + if (Array.isArray(doc.pages)) { + for (const page of doc.pages) { + visit(page?.children); + } + } + + return stats; +} + +function formatBytes(bytes: number | null): string { + if (bytes == null || Number.isNaN(bytes)) return 'unknown'; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KiB`; + return `${(bytes / (1024 * 1024)).toFixed(2)}MiB`; +} + +function isConnectionClosedError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybeError = error as { name?: string; message?: string; cause?: unknown }; + const message = maybeError.message ?? ''; + const causeMessage = + typeof maybeError.cause === 'object' && maybeError.cause + ? String((maybeError.cause as { message?: string }).message ?? '') + : ''; + + return ( + maybeError.name === 'AbortError' || + /connection was closed/i.test(message) || + /connection was closed/i.test(causeMessage) || + /abort/i.test(message) + ); +} + /** POST /api/mcp/document — Receives document update from MCP or renderer, triggers SSE broadcast. */ export default defineEventHandler(async (event) => { - const body = await readBody(event); - if (!body?.document) { - throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' }); + const startedAt = Date.now(); + const contentLengthHeader = getRequestHeader(event, 'content-length'); + const bodyBytesHeader = getRequestHeader(event, 'x-openpencil-body-bytes'); + const contentLength = contentLengthHeader + ? Number.parseInt(contentLengthHeader, 10) + : bodyBytesHeader + ? Number.parseInt(bodyBytesHeader, 10) + : null; + const sourceClientIdHeader = getRequestHeader(event, 'x-openpencil-client-id') ?? 'unknown'; + let phase = 'readBody'; + + try { + const body = await readBody(event); + if (!body?.document) { + throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' }); + } + const doc = body.document; + if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) { + throw createError({ statusCode: 400, statusMessage: 'Invalid document format' }); + } + + const stats = collectDocumentStats(doc); + phase = 'setSyncDocument'; + const version = setSyncDocument(doc, body.sourceClientId); + const elapsedMs = Date.now() - startedAt; + + serverLog.info( + `[mcp-document] ok version=${version} sourceClientId=${body.sourceClientId ?? sourceClientIdHeader} ` + + `contentLength=${formatBytes(contentLength)} nodes=${stats.nodeCount} images=${stats.imageCount} ` + + `dataUrlImages=${stats.dataUrlImageCount} dataUrlChars=${stats.dataUrlChars} elapsedMs=${elapsedMs}`, + ); + + return { ok: true, version }; + } catch (error) { + const elapsedMs = Date.now() - startedAt; + const message = error instanceof Error ? error.message : String(error); + + if (isConnectionClosedError(error)) { + serverLog.warn( + `[mcp-document] connection-closed phase=${phase} contentLength=${formatBytes(contentLength)} ` + + `sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`, + ); + + // The client already closed the request while Nitro was still reading it. + // Returning a soft status keeps expected sync churn out of the 500 logs. + setResponseStatus(event, 202, 'Client closed request during MCP document sync'); + return { + ok: false, + aborted: true, + phase, + }; + } + + serverLog.error( + `[mcp-document] failed phase=${phase} contentLength=${formatBytes(contentLength)} ` + + `sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`, + ); + throw error; } - const doc = body.document; - if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) { - throw createError({ statusCode: 400, statusMessage: 'Invalid document format' }); - } - const version = setSyncDocument(doc, body.sourceClientId); - return { ok: true, version }; }); diff --git a/apps/web/server/utils/acp-connection-manager.ts b/apps/web/server/utils/acp-connection-manager.ts new file mode 100644 index 00000000..26b39467 --- /dev/null +++ b/apps/web/server/utils/acp-connection-manager.ts @@ -0,0 +1,79 @@ +import { connectAcpAgent, disconnectAcpAgent } from '@zseven-w/pen-acp'; +import type { AcpConnectionState, AcpConnectResult } from '@zseven-w/pen-acp'; +import type { AcpAgentConfig } from '../../src/types/agent-settings'; + +// Use globalThis so connections survive Vite HMR / Nitro module reloads. +// Without this, re-evaluating this module wipes the Map and existing UI +// sessions get "ACP agent not connected" errors until reconnecting. +const globalStore = globalThis as unknown as { + __acpConnections?: Map; +}; +const connections: Map = + globalStore.__acpConnections ?? (globalStore.__acpConnections = new Map()); + +export function getAcpConnection(agentId: string): AcpConnectionState | undefined { + const conn = connections.get(agentId); + console.log( + `[acp] getAcpConnection(${agentId}) → ${conn ? 'found' : 'MISSING'}, total connections: ${connections.size}, keys: [${Array.from(connections.keys()).join(', ')}]`, + ); + return conn; +} + +export async function connectAcp( + agentId: string, + config: AcpAgentConfig, +): Promise { + // Server-side safety: reject local mode in hosted production web deployments + // where spawning arbitrary processes is a security risk. Allow it when: + // - Running under Electron (process.versions.electron set) + // - Running in dev mode (NODE_ENV !== 'production') + // - OPENPENCIL_ALLOW_LOCAL_ACP=1 (explicit opt-in for self-hosted non-Electron) + // Note: Nitro server runs in a Vite worker in dev, so process.versions.electron + // is undefined even during electron:dev — hence the NODE_ENV check. + const isElectron = !!process.versions.electron; + const isDev = process.env.NODE_ENV !== 'production'; + const isAllowed = process.env.OPENPENCIL_ALLOW_LOCAL_ACP === '1'; + if (config.connectionType === 'local' && !isElectron && !isDev && !isAllowed) { + return { + connected: false, + error: + 'Local agents are only available in the desktop app. Set OPENPENCIL_ALLOW_LOCAL_ACP=1 to enable in self-hosted deployments.', + }; + } + + // Disconnect existing if any + if (connections.has(agentId)) { + await disconnectAcp(agentId); + } + + try { + console.log( + `[acp] connecting ${agentId} (${config.connectionType}: ${config.command ?? config.url})`, + ); + const state = await connectAcpAgent(config); + connections.set(agentId, state); + console.log(`[acp] connected ${agentId}, total connections: ${connections.size}`); + return { connected: true, agentInfo: state.agentInfo }; + } catch (err) { + console.error(`[acp] connect failed ${agentId}:`, err); + return { + connected: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function disconnectAcp(agentId: string): Promise { + const state = connections.get(agentId); + if (state) { + disconnectAcpAgent(state); + connections.delete(agentId); + } +} + +export function cleanupAllAcp(): void { + for (const [id, state] of connections) { + disconnectAcpAgent(state); + connections.delete(id); + } +} diff --git a/apps/web/server/utils/agent-sessions.ts b/apps/web/server/utils/agent-sessions.ts index 035b2358..7ee86122 100644 --- a/apps/web/server/utils/agent-sessions.ts +++ b/apps/web/server/utils/agent-sessions.ts @@ -5,6 +5,7 @@ import type { ToolRegistryHandle, TeamHandle, } from '@zseven-w/agent-native'; +import type { ClientSideConnection } from '@agentclientprotocol/sdk'; import type { LayoutPhase } from './agent-tool-guard'; import { abortEngine, @@ -16,7 +17,8 @@ import { destroyTeam, } from '@zseven-w/agent-native'; -export interface AgentSession { +export interface NativeAgentSession { + type: 'native'; engine?: QueryEngineHandle; team?: TeamHandle; iter?: IteratorHandle; @@ -36,20 +38,36 @@ export interface AgentSession { layoutRootId: string | null; } -/** Create a session with required defaults. */ +export interface AcpAgentSession { + type: 'acp'; + acpSessionId: string; + acpAgentId: string; + connection: ClientSideConnection; + createdAt: number; + lastActivity: number; + toolNames: Map; + toolOwners: Map; + layoutPhase: LayoutPhase; + layoutRootId: string | null; +} + +export type AgentSession = NativeAgentSession | AcpAgentSession; + +/** Create a native session with required defaults. */ export function createSession( fields: Omit< - AgentSession, - 'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId' + NativeAgentSession, + 'type' | 'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId' > & Partial< Pick< - AgentSession, + NativeAgentSession, 'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId' > >, -): AgentSession { +): NativeAgentSession { return { + type: 'native', ...fields, toolOwners: fields.toolOwners ?? new Map(), toolNames: fields.toolNames ?? new Map(), @@ -59,6 +77,24 @@ export function createSession( }; } +/** Create an ACP session with required defaults. */ +export function createAcpSession(fields: { + acpSessionId: string; + acpAgentId: string; + connection: ClientSideConnection; +}): AcpAgentSession { + return { + type: 'acp', + ...fields, + createdAt: Date.now(), + lastActivity: Date.now(), + toolNames: new Map(), + toolOwners: new Map(), + layoutPhase: 'idle', + layoutRootId: null, + }; +} + export const agentSessions = new Map(); /** Mark a session as active so long-running external tool callbacks are not expired. */ @@ -68,6 +104,7 @@ export function touchSession(session: Pick, now = /** Idempotent cleanup — nullifies handles after destroying to prevent double-free. */ export function cleanup(session: AgentSession): void { + if (session.type === 'acp') return; // ACP connections managed by acp-connection-manager if (session.iter) { destroyIterator(session.iter); session.iter = undefined; @@ -100,6 +137,12 @@ export function cleanup(session: AgentSession): void { /** Abort a session — makes pending nextEvent resolve null. */ export function abortSession(session: AgentSession): void { + if (session.type === 'acp') { + try { + (session.connection as any).cancel?.({ sessionId: session.acpSessionId }); + } catch {} + return; + } if (session.team) abortTeam(session.team); else if (session.engine) abortEngine(session.engine); } diff --git a/apps/web/server/utils/mcp-sync-state.ts b/apps/web/server/utils/mcp-sync-state.ts index effb0c98..a5a00c2d 100644 --- a/apps/web/server/utils/mcp-sync-state.ts +++ b/apps/web/server/utils/mcp-sync-state.ts @@ -65,13 +65,20 @@ export function unregisterSSEClient(id: string): void { } function broadcast(payload: Record, excludeClientId?: string): void { - const data = JSON.stringify(payload); + const recipients: SSEClient[] = []; for (const [id, client] of clients) { if (id === excludeClientId) continue; + recipients.push(client); + } + + if (recipients.length === 0) return; + + const data = JSON.stringify(payload); + for (const client of recipients) { try { client.writer.push(data); } catch { - clients.delete(id); + clients.delete(client.id); } } } diff --git a/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts b/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts index 38fb3936..976b1c52 100644 --- a/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts +++ b/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createEmptyDocument } from '@/stores/document-tree-utils'; import { useCanvasStore } from '@/stores/canvas-store'; @@ -70,8 +70,11 @@ function resetStores() { } describe('SkiaInteractionManager continuous interaction commits', () => { - it('defers resize store writes until mouseup', () => { + beforeEach(() => { resetStores(); + }); + + it('defers resize store writes until mouseup', () => { let node: any = { id: 'path-1', type: 'path', @@ -143,7 +146,6 @@ describe('SkiaInteractionManager continuous interaction commits', () => { }); it('defers rotate store writes until mouseup', () => { - resetStores(); let node: any = { id: 'rect-1', type: 'rectangle', @@ -199,7 +201,6 @@ describe('SkiaInteractionManager continuous interaction commits', () => { }); it('defers arc handle store writes until mouseup', () => { - resetStores(); let node: any = { id: 'ellipse-1', type: 'ellipse', @@ -253,4 +254,101 @@ describe('SkiaInteractionManager continuous interaction commits', () => { expect(updateNodeCalls).toHaveLength(1); expect(updateNodeCalls[0]?.[1]).toHaveProperty('innerRadius'); }); + + it('keeps an image-backed node selected instead of auto-selecting its parent frame', () => { + const frame = { + id: 'frame-1', + type: 'frame', + x: 0, + y: 0, + width: 300, + height: 300, + children: [], + } as PenNode; + const imageBackedRect = { + id: 'child-1', + type: 'rectangle', + x: 10, + y: 20, + width: 120, + height: 80, + fill: [{ type: 'image', url: 'memory://image.png' }], + } as PenNode; + + useDocumentStore.setState({ + getNodeById: (id: string) => { + if (id === frame.id) return frame; + if (id === imageBackedRect.id) return imageBackedRect; + return undefined; + }, + getParentOf: (id: string) => (id === imageBackedRect.id ? frame : null), + isDescendantOf: () => false, + } as any); + + const engine = createEngineStub([ + { + node: { ...imageBackedRect }, + absX: 10, + absY: 20, + absW: 120, + absH: 80, + }, + ]); + (engine as any).spatialIndex.hitTest = () => [{ node: imageBackedRect } as any]; + + const manager = new SkiaInteractionManager( + { current: engine as any }, + createCanvasStub(), + () => {}, + ) as any; + + manager.handleSelectMouseDown( + { shiftKey: false } as MouseEvent, + { x: 20, y: 30 }, + engine as any, + ); + + expect(useCanvasStore.getState().selection.selectedIds).toEqual(['child-1']); + expect(useCanvasStore.getState().selection.activeId).toBe('child-1'); + }); + + it('moves clip rects together with dragged render nodes', () => { + const node = { + id: 'frame-1', + type: 'frame', + x: 50, + y: 60, + width: 200, + height: 120, + children: [], + } as PenNode; + + const renderNode = { + node: { ...node }, + absX: 50, + absY: 60, + absW: 200, + absH: 120, + clipRect: { x: 45, y: 55, w: 210, h: 130, rx: 8 }, + }; + const engine = createEngineStub([renderNode]); + const manager = new SkiaInteractionManager( + { current: engine as any }, + createCanvasStub(), + () => {}, + ) as any; + + manager.isDragging = true; + manager.dragNodeIds = ['frame-1']; + manager.dragStartSceneX = 0; + manager.dragStartSceneY = 0; + + manager.handleDragMove({ x: 20, y: 15 }, engine as any); + + expect(renderNode.absX).toBe(70); + expect(renderNode.absY).toBe(75); + expect(renderNode.clipRect).toMatchObject({ x: 65, y: 70, w: 210, h: 130, rx: 8 }); + expect(engine.rebuildCount).toBeGreaterThan(0); + expect(engine.dirtyCount).toBeGreaterThan(0); + }); }); diff --git a/apps/web/src/canvas/skia/skia-interaction.ts b/apps/web/src/canvas/skia/skia-interaction.ts index 5e3963db..2f8f4799 100644 --- a/apps/web/src/canvas/skia/skia-interaction.ts +++ b/apps/web/src/canvas/skia/skia-interaction.ts @@ -79,6 +79,13 @@ export function toolToCursor(tool: ToolType): string { } } +function hasImageVisual(node: PenNode | undefined): boolean { + if (!node) return false; + if (node.type === 'image') return true; + if (!('fill' in node)) return false; + return Array.isArray(node.fill) && node.fill.some((fill: any) => fill?.type === 'image'); +} + /** * Encapsulates all canvas mouse/keyboard interaction state and handlers. * Extracted from SkiaCanvas to keep the component focused on lifecycle and rendering. @@ -371,8 +378,13 @@ export class SkiaInteractionManager { if (isChildOfSelected) { // Don't change selection } else if (!currentSelection.includes(nodeId)) { + const clickedNode = docStore.getNodeById(nodeId); const parent = docStore.getParentOf(nodeId); - if (parent && (parent.type === 'frame' || parent.type === 'group')) { + if ( + !hasImageVisual(clickedNode) && + parent && + (parent.type === 'frame' || parent.type === 'group') + ) { const grandparent = docStore.getParentOf(parent.id); if (!grandparent || grandparent.type === 'frame') { nodeId = parent.id; @@ -906,6 +918,13 @@ export class SkiaInteractionManager { if (this.dragAllIds!.has(rn.node.id)) { rn.absX += incrDx; rn.absY += incrDy; + if (rn.clipRect) { + rn.clipRect = { + ...rn.clipRect, + x: rn.clipRect.x + incrDx, + y: rn.clipRect.y + incrDy, + }; + } rn.node = { ...rn.node, x: rn.absX, y: rn.absY }; } } diff --git a/apps/web/src/components/panels/ai-chat-handlers.ts b/apps/web/src/components/panels/ai-chat-handlers.ts index feef34dd..de12669a 100644 --- a/apps/web/src/components/panels/ai-chat-handlers.ts +++ b/apps/web/src/components/panels/ai-chat-handlers.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { nanoid } from 'nanoid'; import i18n from '@/i18n'; import type { AgentEvent } from '@/types/agent'; +import type { AIProviderType } from '@/types/agent-settings'; function decodeAgentEvent(raw: string): AgentEvent | null { const eventMatch = raw.match(/^event:\s*(\S+)/); @@ -41,6 +42,7 @@ import type { ToolCallBlockData } from '@/components/panels/tool-call-block'; import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'; import { classifyIntent } from './ai-chat-intent-classifier'; import { buildContextString } from './ai-chat-context-builder'; +import { detectAgentIntent, getCrudToolDefs } from '@/services/ai/agent-tools'; // Re-export for any external consumers export { buildContextString } from './ai-chat-context-builder'; @@ -74,22 +76,40 @@ RULE 3: Do NOT call generate_design more than once unless the user asks for a ne FORBIDDEN: Do not output JSON, code blocks, or node definitions directly. Always use generate_design instead.`; +/** Lightweight prompt for CRUD operations — no design skills, just tool usage. */ +const AGENT_TOOL_INSTRUCTIONS_CRUD = `You are a design editor. Use tools to inspect, modify, insert, and delete elements on the canvas. + +WORKFLOW: +1. Use snapshot_layout or batch_get FIRST to see the tree structure and find node IDs. +2. Use the appropriate tool: insert_node to add, update_node to modify, delete_node to remove, move_node to reparent. +3. When inserting, use "after" parameter with a sibling ID to place the new node in the correct position. +4. After each operation, write 1-2 sentences summarizing what changed. + +INSERT_NODE GUIDE — always include complete node data with children: +- Button example: {"type":"frame","name":"My Button","width":"fill_container","height":50,"cornerRadius":8,"fill":[{"type":"solid","color":"#1877F2"}],"layout":"horizontal","gap":8,"alignItems":"center","justifyContent":"center","children":[{"type":"icon_font","name":"Icon","iconName":"facebook","width":20,"height":20,"fill":[{"type":"solid","color":"#FFFFFF"}]},{"type":"text","name":"Label","text":"Continue with Facebook","fontSize":15,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}]} +- Text example: {"type":"text","name":"Title","text":"Hello","fontSize":24,"fontWeight":700,"fill":[{"type":"solid","color":"#1A1A2E"}]} +- When adding next to a similar element, use batch_get to read that element's full data first, then create matching structure. + +Focus on the specific operation the user requested.`; + /** Agent instructions for lead agents coordinating a team. */ const AGENT_TOOL_INSTRUCTIONS_TEAM = `You are a design lead coordinating a team. Do not create the design directly in this mode. Analyze the request, delegate the work to team members, then summarize the outcome for the user.`; /** - * Build the agent system prompt based on provider type. - * Builtin providers get direct design instructions (plan_layout + batch_insert). - * CLI providers get generate_design tool instructions (orchestrator pipeline). + * Build the agent system prompt based on provider type and detected intent. + * CRUD intents (read/update/delete) get a lightweight prompt with no design skills. + * Design intents get the full design generation pipeline. */ function buildAgentSystemPrompt( - _userMessage: string, + userMessage: string, isBuiltin: boolean, teamMode: boolean, ): string { if (teamMode) return AGENT_TOOL_INSTRUCTIONS_TEAM; + const intent = detectAgentIntent(userMessage); + if (intent === 'crud') return AGENT_TOOL_INSTRUCTIONS_CRUD; return isBuiltin ? AGENT_TOOL_INSTRUCTIONS_BUILTIN : AGENT_TOOL_INSTRUCTIONS_CLI; } @@ -128,7 +148,7 @@ async function* parseAgentSSE( /** Provider config for the agent pipeline */ interface AgentProviderConfig { - providerType: 'anthropic' | 'openai-compat'; + providerType: 'anthropic' | 'openai-compat' | 'acp'; apiKey: string; model: string; baseURL?: string; @@ -204,7 +224,8 @@ async function runAgentStream( const { useAIStore: concurrencyStore } = await import('@/stores/ai-store'); const concurrency = concurrencyStore.getState().concurrency; const teamMode = concurrency > 1; - const toolDefs = getDesignToolDefs(); + const intent = detectAgentIntent(lastUserMsg); + const toolDefs = intent === 'crud' ? getCrudToolDefs() : getDesignToolDefs(); const systemPrompt = buildAgentSystemPrompt(lastUserMsg, isBuiltin, teamMode) + context; const agentBody: Record = { @@ -226,6 +247,15 @@ async function runAgentStream( ...(hasVariables ? { hasVariables } : {}), }; + // ACP: add agentId + config to the request body (config enables server-side + // auto-reconnect if the in-memory connection was lost due to dev server restart). + if (providerConfig.providerType === 'acp') { + const agentId = providerConfig.model.slice(4); + const acpConfig = useAgentSettingsStore.getState().acpAgents.find((a) => a.id === agentId); + (agentBody as any).acpAgentId = agentId; + if (acpConfig) (agentBody as any).acpConfig = acpConfig; + } + const response = await fetch('/api/ai/agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -235,7 +265,17 @@ async function runAgentStream( if (!response.ok || !response.body) { const errText = await response.text().catch(() => 'Unknown error'); - throw new Error(`Agent request failed: ${errText}`); + // h3 errors come as JSON: { message, error, status, ... } — extract just the message. + let errorMessage = errText; + try { + const parsed = JSON.parse(errText); + if (parsed && typeof parsed === 'object' && typeof parsed.message === 'string') { + errorMessage = parsed.message; + } + } catch { + /* not JSON — use raw text */ + } + throw new Error(errorMessage); } const reader = response.body.getReader(); @@ -254,6 +294,10 @@ async function runAgentStream( let identityPool: AgentIdentity[] = []; let nextIdentityIdx = 0; const memberIdentities = new Map(); + // Track the most recent failed tool call so terminal `error_server` events + // (which carry no detail from the Zig engine) can surface what actually broke. + const toolNames = new Map(); + let lastToolError: { name: string; message: string } | null = null; try { for await (const evt of parseAgentSSE(reader, abortController.signal)) { @@ -293,6 +337,7 @@ async function runAgentStream( // Skip internal team coordination tools — they are resolved by agent-team, not the client if (evt.level === 'orchestrate') break; + toolNames.set(evt.id, evt.name); executor .execute(evt as Extract) .then((result) => { @@ -303,12 +348,19 @@ async function runAgentStream( result: result ?? undefined, }); } + if (result && result.success === false) { + lastToolError = { + name: evt.name, + message: String(result.error ?? 'unknown error'), + }; + } }) .catch((err) => { useAIStore.getState().updateToolCallBlock(evt.id, { status: 'error', result: { success: false, error: String(err) }, }); + lastToolError = { name: evt.name, message: String(err) }; }); break; } @@ -319,6 +371,12 @@ async function runAgentStream( status: evt.result.success ? 'done' : 'error', result: evt.result, }); + if (!evt.result.success) { + lastToolError = { + name: toolNames.get(evt.id) ?? 'tool', + message: String((evt.result as { error?: unknown }).error ?? 'unknown error'), + }; + } break; } @@ -356,7 +414,15 @@ async function runAgentStream( } case 'error': { - accumulated += `\n\n**Error:** ${evt.message}`; + // Terminal `Agent error: error_server` events from the Zig engine + // carry no detail. Fall back to the last failed tool call so the + // user sees the actual cause (e.g. an upstream 529 surfaced via + // a tool error, or a runtime exception inside the design pipeline). + let detail = ''; + if (lastToolError && /^Agent error:/i.test(evt.message)) { + detail = `\n> Last tool failure (\`${lastToolError.name}\`): ${lastToolError.message}`; + } + accumulated += `\n\n**Error:** ${evt.message}${detail}`; updateLastMessage(accumulated); renderer.finish(); if (evt.fatal) return stripThinkTags(accumulated); @@ -544,6 +610,64 @@ export function useChatHandlers() { return; } + // ----------------------------------------------------------------------- + // ACP AGENT MODE — routes to ACP agent via runAgentStream() + // ----------------------------------------------------------------------- + if (model.startsWith('acp:')) { + const agentId = model.slice(4); + const { acpAgents } = useAgentSettingsStore.getState(); + const acpConfig = acpAgents.find((a: any) => a.id === agentId); + if (!acpConfig) { + useAIStore.setState((s) => { + const msgs = [...s.messages]; + const last = msgs[msgs.length - 1]; + if (last) { + last.content = 'ACP agent not found. Please check your settings.'; + last.isStreaming = false; + } + return { messages: msgs }; + }); + return; + } + + useAIStore.getState().clearToolCallBlocks(); + try { + await runAgentStream( + assistantMsg.id, + { + providerType: 'acp', + apiKey: 'acp', + model: model, + }, + abortController, + ); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + useAIStore.setState((s) => { + const msgs = [...s.messages]; + const last = msgs[msgs.length - 1]; + if (last) { + last.content = last.content + ? `${last.content}\n\n**Error:** ${errorMsg}` + : `**Error:** ${errorMsg}`; + last.isStreaming = false; + } + return { messages: msgs }; + }); + } finally { + // Always clear streaming state — both on success and on error. + useAIStore.getState().setAbortController(null); + setStreaming(false); + useAIStore.setState((s) => { + const msgs = [...s.messages]; + const last = msgs[msgs.length - 1]; + if (last?.isStreaming) last.isStreaming = false; + return { messages: msgs }; + }); + } + return; + } + // ----------------------------------------------------------------------- // STANDARD MODE — design/chat pipeline (external CLI providers) // ----------------------------------------------------------------------- @@ -596,7 +720,7 @@ export function useChatHandlers() { themes: modDoc.themes, designMd: useDesignMdStore.getState().designMd, model, - provider: currentProvider, + provider: currentProvider as AIProviderType | undefined, }, abortController.signal, ); @@ -612,7 +736,7 @@ export function useChatHandlers() { { prompt: fullUserMessage, model, - provider: currentProvider, + provider: currentProvider as AIProviderType | undefined, concurrency, context: { canvasSize: { width: 1200, height: 800 }, diff --git a/apps/web/src/components/panels/ai-chat-input.tsx b/apps/web/src/components/panels/ai-chat-input.tsx index 21399811..10d44cee 100644 --- a/apps/web/src/components/panels/ai-chat-input.tsx +++ b/apps/web/src/components/panels/ai-chat-input.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from 'react'; -import { Send, ChevronUp, Paperclip, X, Square, Key } from 'lucide-react'; +import { Send, ChevronUp, Paperclip, X, Square, Key, Plug } from 'lucide-react'; import { nanoid } from 'nanoid'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; @@ -157,25 +157,17 @@ export function AIChatInput({ input, setInput, onSend }: AIChatInputProps) { > {(() => { if (model.startsWith('builtin:')) { - const currentGroup = modelGroups.find((g) => - g.models.some((m) => m.value === model), - ); - if (currentGroup) { - const ProvIcon = PROVIDER_ICON[currentGroup.provider]; - return ProvIcon ? ( - - ) : ( - - ); - } return ; } + if (model.startsWith('acp:')) { + return ; + } const currentProvider = modelGroups.find((g) => g.models.some((m) => m.value === model), )?.provider; if (currentProvider) { - const ProvIcon = PROVIDER_ICON[currentProvider]; - return ; + const ProvIcon = PROVIDER_ICON[currentProvider as keyof typeof PROVIDER_ICON]; + return ProvIcon ? : null; } return null; })()} diff --git a/apps/web/src/components/panels/ai-chat-model-selector.tsx b/apps/web/src/components/panels/ai-chat-model-selector.tsx index 7ac45075..b2288dae 100644 --- a/apps/web/src/components/panels/ai-chat-model-selector.tsx +++ b/apps/web/src/components/panels/ai-chat-model-selector.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { Check, Search, X, Zap, Key } from 'lucide-react'; +import { Check, Search, X, Zap, Key, Plug } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import { useAIStore } from '@/stores/ai-store'; @@ -119,17 +119,20 @@ export function ModelDropdown({ open, onClose }: { open: boolean; onClose: () => } return filtered.map((group, groupIdx) => { - const GIcon = PROVIDER_ICON[group.provider]; + const GIcon = PROVIDER_ICON[group.provider as AIProviderType]; const groupKey = `${group.provider}-${group.providerName}-${groupIdx}`; const isBuiltinGroup = group.models.some((m) => m.value.startsWith('builtin:')); + const isAcpGroup = group.models.some((m) => m.value.startsWith('acp:')); return (
{isBuiltinGroup ? ( - ) : ( + ) : isAcpGroup ? ( + + ) : GIcon ? ( - )} + ) : null} {group.providerName} diff --git a/apps/web/src/components/panels/ai-chat-panel.tsx b/apps/web/src/components/panels/ai-chat-panel.tsx index 34cccbf8..4c6afbca 100644 --- a/apps/web/src/components/panels/ai-chat-panel.tsx +++ b/apps/web/src/components/panels/ai-chat-panel.tsx @@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button'; import { useAIStore } from '@/stores/ai-store'; import type { PanelCorner } from '@/stores/ai-store'; import { useAgentSettingsStore } from '@/stores/agent-settings-store'; -import type { AIProviderType } from '@/types/agent-settings'; +import type { AIProviderType, ModelGroup } from '@/types/agent-settings'; import { useChatHandlers } from './ai-chat-handlers'; import { resolveNextModel } from './ai-chat-model-selector'; import { AIChatMessageList } from './ai-chat-message-list'; @@ -108,6 +108,8 @@ export default function AIChatPanel() { const providers = useAgentSettingsStore((s) => s.providers); const builtinProviders = useAgentSettingsStore((s) => s.builtinProviders); const providersHydrated = useAgentSettingsStore((s) => s.isHydrated); + const acpAgents = useAgentSettingsStore((s) => s.acpAgents); + const acpConnectionStatus = useAgentSettingsStore((s) => s.acpConnectionStatus); const { input, setInput, handleSend } = useChatHandlers(); const canUseModel = !isLoadingModels && availableModels.length > 0; @@ -137,7 +139,7 @@ export default function AIChatPanel() { (p) => providers[p].isConnected && (providers[p].models?.length ?? 0) > 0, ); - const groups = connectedProviders.map((p) => ({ + const groups: ModelGroup[] = connectedProviders.map((p) => ({ provider: p, providerName: providerNames[p], models: providers[p].models, @@ -162,6 +164,25 @@ export default function AIChatPanel() { }); } + // ACP agents + for (const agent of acpAgents) { + const status = acpConnectionStatus[agent.id]; + if (status?.isConnected) { + groups.push({ + provider: 'acp', + providerName: `${agent.displayName} (ACP)`, + models: [ + { + value: `acp:${agent.id}`, + displayName: agent.displayName, + description: status.agentInfo?.title ?? 'ACP Agent', + provider: 'acp', + }, + ], + }); + } + } + if (groups.length > 0) { const flat = groups.flatMap((g) => g.models.map((m) => ({ @@ -184,7 +205,7 @@ export default function AIChatPanel() { setModelGroups([]); setAvailableModels([]); setLoadingModels(false); - }, [providers, builtinProviders, providersHydrated]); // eslint-disable-line react-hooks/exhaustive-deps + }, [providers, builtinProviders, providersHydrated, acpAgents, acpConnectionStatus]); // eslint-disable-line react-hooks/exhaustive-deps // Auto-expand when streaming starts while minimized useEffect(() => { diff --git a/apps/web/src/components/shared/acp-agent-settings.tsx b/apps/web/src/components/shared/acp-agent-settings.tsx new file mode 100644 index 00000000..bb00ef32 --- /dev/null +++ b/apps/web/src/components/shared/acp-agent-settings.tsx @@ -0,0 +1,461 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, Sparkles, Globe, Terminal, Pencil, Trash2, Plug, Unplug } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { useAgentSettingsStore } from '@/stores/agent-settings-store'; +import type { AcpAgentConfig } from '@/types/agent-settings'; + +/* ---------- Shared field wrapper ---------- */ +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +const inputClass = + 'w-full h-8 px-2.5 text-[12px] bg-background text-foreground rounded-md border border-input focus:border-ring focus:ring-1 focus:ring-ring/20 outline-none transition-all'; + +/* ---------- Helpers ---------- */ + +/** Check whether we are running inside the Electron shell */ +function isElectron(): boolean { + return ( + typeof window !== 'undefined' && !!(window as unknown as Record).electronAPI + ); +} + +/** Parse KEY=VALUE lines into a record */ +function parseEnvText(text: string): Record { + const env: Record = {}; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.includes('=')) continue; + const idx = trimmed.indexOf('='); + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (key) env[key] = value; + } + return env; +} + +/** Serialize a record to KEY=VALUE lines */ +function envToText(env?: Record): string { + if (!env) return ''; + return Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join('\n'); +} + +/* ---------- AcpAgentForm ---------- */ +export function AcpAgentForm({ + initial, + onSave, + onCancel, +}: { + initial?: AcpAgentConfig; + onSave: (data: Omit) => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const electron = isElectron(); + + const [displayName, setDisplayName] = useState(initial?.displayName ?? ''); + const [connectionType, setConnectionType] = useState<'local' | 'remote'>( + initial?.connectionType ?? (electron ? 'local' : 'remote'), + ); + const [command, setCommand] = useState(initial?.command ?? ''); + const [args, setArgs] = useState(initial?.args?.join(', ') ?? ''); + const [envText, setEnvText] = useState(envToText(initial?.env)); + const [url, setUrl] = useState(initial?.url ?? ''); + + const canSave = + displayName.trim().length > 0 && + (connectionType === 'local' ? command.trim().length > 0 : url.trim().length > 0); + + const handleSave = useCallback(() => { + const base = { + displayName: displayName.trim(), + connectionType, + enabled: initial?.enabled ?? true, + }; + + if (connectionType === 'local') { + const parsedArgs = args + .split(',') + .map((a) => a.trim()) + .filter(Boolean); + const parsedEnv = parseEnvText(envText); + onSave({ + ...base, + command: command.trim(), + args: parsedArgs.length > 0 ? parsedArgs : undefined, + env: Object.keys(parsedEnv).length > 0 ? parsedEnv : undefined, + }); + } else { + onSave({ + ...base, + url: url.trim(), + }); + } + }, [displayName, connectionType, command, args, envText, url, initial?.enabled, onSave]); + + return ( +
+ {/* Header */} +
+
+ +
+ + {initial ? t('common.save') : t('acp.addAgent')} + +
+ +
+ {/* Display Name */} + + setDisplayName(e.target.value)} + placeholder={t('acp.displayNamePlaceholder')} + className={inputClass} + /> + + + {/* Connection Type toggle */} + +
+ {electron && ( + + )} + +
+
+ + {/* Local-mode fields */} + {connectionType === 'local' && ( + <> + + setCommand(e.target.value)} + placeholder={t('acp.commandPlaceholder')} + className={cn(inputClass, 'font-mono')} + /> + + + + setArgs(e.target.value)} + placeholder={t('acp.argsPlaceholder')} + className={cn(inputClass, 'font-mono text-[11px]')} + /> + + + +