From b51d069ea6a6c6af3d534d784177365cea43dc58 Mon Sep 17 00:00:00 2001 From: Kayshen Xu Date: Mon, 13 Apr 2026 21:30:23 +0800 Subject: [PATCH] V0.7.1 (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop,web): rebuild Electron dev sync + bitmap dragging fix on v0.7.1 (#99) Re-applies b046a0d from the closed PR #97 now that the base is v0.7.1. Original conflict against v0.7.0 came from the release branch churn — the cherry-pick onto v0.7.1 applies cleanly. Keeps dev-startup, sync-noise, and bitmap-dragging fixes; drops the loopback proxy helper scripts upstream rejected in PR #92. Readiness probe now does direct socket checks inside the existing dev entrypoint; sync hardening stays focused on request diagnostics, backpressure, and drag-time clip-rect correctness. Original commit: b046a0d Supersedes: #97 (closed, head branch deleted) Co-authored-by: Rais * fix(canvas): use ImageFill.url in skia-interaction test The image-backed rectangle fixture in the skia-interaction test used `{ type: 'image', src: '…' }`, but `ImageFill` in pen-types declares the field as `url`. `npx tsc --noEmit` flagged it as TS2352 on the `as PenNode` cast. One-word rename. The squash-merge of #99 captured an earlier snapshot that did not include this fix, so re-apply directly on v0.7.1. * feat(cli): add `op install` / `op uninstall` for openpencil-skill Bundle skill files at build time (scripts/bundle-skill.ts → skill-bundle.json) so users without GitHub access can install directly. Falls back to git clone when the bundle is empty. Supports auto-detection of: Claude Code, Codex, Cursor, Gemini CLI, OpenCode. CI workflows updated to checkout openpencil-skill before cli:compile. * fix(panels): allow reparenting nodes into rectangle in layer panel CONTAINER_TYPES was missing 'rectangle', preventing drag-drop into rectangles even though the data model (ContainerProps) and store (moveNode) both support it. * fix(agent,ai): tool_exec reset + insert_node with after + move_node + CRUD tools - fix(agent): reset StreamingToolExecutor between turns — prevents stale tool_use IDs that caused 400 errors on multi-turn tool calls (MiniMax etc.) - feat(ai): add insert_node "after" parameter — auto-resolves sibling's parent and position for intuitive node insertion - feat(ai): add move_node and insert_node to CRUD tool set - feat(ai): add Chinese keywords (增加/添加/插入) to design intent detection - fix(ai): insert_node uses addNode directly for existing parents instead of streaming pipeline, fixing parent resolution * feat(ai): route CRUD intents to lightweight prompt and tool set CRUD operations (read/update/delete) now get a focused system prompt without design generation instructions, and use getCrudToolDefs() (which includes insert_node and move_node) instead of the full design set. * fix(agent,mcp): submodule update + MCP tool improvements - Update agent-native submodule (tool_exec reset, HTTP error diagnostics) - Improve MCP tool descriptions and parameter schemas - Enhance agent.ts error handling * feat(ai): add PenNode examples to CRUD prompt for complete node generation The CRUD system prompt now includes button and text node examples showing the full structure (fills, children, icons, layout) so models generate complete nodes instead of empty frames. * Update agent-native submodule to commit f9633a8, ensuring compatibility with recent changes and improvements in the agent's functionality. * fix(ci,agent): generate skill-bundle before type check + fix moveNode arg - Add `bun run cli:bundle-skill` step in CI before `tsc --noEmit` so skill-bundle.json exists when type-checking the CLI - Fix moveNode index parameter: default to -1 when undefined * fix(ci): add pen-engine and pen-react to npm publish workflow Insert pen-engine and pen-react in topological order between pen-renderer and pen-mcp so they are published before pen-sdk. * docs: add MIT LICENSE and README to all packages - Add MIT LICENSE to pen-ai-skills, pen-core, pen-engine, pen-figma, pen-mcp, pen-react, pen-renderer, pen-sdk, pen-types - Add README.md to pen-engine, pen-react, pen-mcp, pen-ai-skills * docs: comprehensive package metadata, README, CLAUDE.md, and LICENSE - Add author, license, repository, bugs, homepage to all package.json - Homepage points to each package's own directory on GitHub - Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills with full API docs, usage examples, and feature tables - Add CLAUDE.md to pen-types, pen-core, pen-engine, pen-figma, pen-mcp, pen-react, pen-renderer, pen-sdk - Add MIT LICENSE to all packages - Update root CLAUDE.md with index of all sub-CLAUDE.md files - Fix git URL from nicepkg → ZSeven-W * docs: package metadata, README, LICENSE, and CLAUDE.md for all packages - Add author, license, repository, bugs, homepage to all package.json - Homepage points to each package's own directory on GitHub - Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills with full API docs, usage examples, and feature tables - Add MIT LICENSE to all packages missing it - Add CLAUDE.md to pen-types - Update root CLAUDE.md with index of all sub-CLAUDE.md files - Fix git URL from nicepkg to ZSeven-W * docs: rewrite README for pen-core, pen-figma, pen-renderer, pen-sdk Comprehensive READMEs with full API reference, usage examples, feature tables, and architecture overview for each package. * feat(acp): acpAgents store — persist, hydrate, CRUD actions * feat(acp): pen-acp package + agent settings types + store - pen-acp/types.ts: AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpConnectionState - pen-acp/client.ts: connectAcpAgent (local stdio + remote WebSocket), disconnectAcpAgent - pen-acp/event-adapter.ts: acpUpdateToSSE (ACP session/update → SSE events) - agent-settings.ts: AcpAgentConfig type, widen ModelGroup/GroupedModel.provider - agent-settings-store.ts: acpAgents persist/hydrate/CRUD + acpConnectionStatus * fix(acp): remove unused type imports in client.ts * feat(acp): ACP agent settings UI — form, cards, connect/disconnect * feat(acp): add AcpAgentSection to Agents settings tab * refactor(agent): AgentSession as discriminated union (native | acp) * feat(acp): connection manager — connect, disconnect, cleanup * feat(acp): connect/disconnect API route * feat(acp): ACP branch in agent result handler * fix(agent): use NativeAgentSession type for runDelegateMember * feat(acp): ACP prompt SSE stream in agent endpoint * feat(acp): ACP agents in model list + request routing * i18n(acp): add ACP agent translation keys for all 15 locales * fix(i18n): translate ACP keys for all 15 locales + fix missing key references - zh.ts/zh-tw.ts: proper Chinese translations (Agent not translated) - All other locales: translated from English placeholders - Fix acp.add → acp.addAgent, acp.disconnected → acp.notConnected * chore: bump agent-native submodule — surface upstream HTTP errors * feat(acp,build,codegen): comprehensive fixes for ACP integration + prod build ACP agent integration: - Rewrite system prompt to enforce layered design pipeline (get_design_prompt → design_skeleton → design_content → design_refine) for higher quality output - Use correct PenNode field names: content for text, iconFontName for icons - Strict JSON rules to prevent empty-key / trailing-comma / smart-quote errors - Prefer icon_font over path icons (standalone MCP has no hooks registered) - Auto-start MCP server before ACP session (lazy bootstrap) - Auto-reconnect ACP on stale connection (dev server restart scenario) - Auto-approve tool permission requests (trust model: user configured agent) - Use type: 'http' + headers: [] for MCP server config (SDK schema requirement) - Persist ACP connections via globalThis so they survive Vite HMR Build / packaging: - Place agent-native under server/node_modules for Nitro to resolve at runtime - Copy agent_napi.node to napi/ as extraResource - Kill detached MCP server on Electron quit (before-quit + dev SIGINT handlers) - Capture drag-dropped filesystem path via webUtils.getPathForFile so recent files entries are clickable after reopening Codegen: - Compact JSON (no indent) + strip noise fields (id, parentId, default rotation /opacity/visible, layout-managed x/y) to reduce request body size by 60-70% so proxies don't reject with 403 'Request not allowed' MCP batch_design robustness: - splitOperations tracks bracket/quote balance → multi-line JSON now works - Auto-normalize fill/stroke shorthand forms - Collect per-line errors instead of aborting whole batch - Repair empty keys, trailing commas, smart quotes in JSON - Bindless I(...) form supported (auto-generates binding) UI: - ModelDropdown / ChatInput handle ACP model icons (Plug) - Reset streaming state + abort controller on ACP error path - Strip h3 JSON error wrapper so chat shows clean error messages - ACP agent settings form + cards + connect/disconnect * fix(types): resolve TS errors in CI typecheck - acp-connection-manager.ts: correct relative import path (utils/ → src/types) - ai-chat-handlers.ts: cast currentProvider to AIProviderType at design-generator callsites - ai-chat-panel.tsx: explicitly type groups as ModelGroup[] so 'acp' string fits the widened union - acp-agent-settings.tsx: cast window through unknown for Record lookup - electron.d.ts: add getPathForFile to ElectronAPI declaration - builtin-provider-presets.ts: drop now-redundant config.preset !== 'custom' check (handled by early return) - pen-acp/client.ts: cast Writable/Readable.toWeb to typed Streams; coerce nullish agentInfo fields to undefined --------- Co-authored-by: Rais --- .github/workflows/build-electron.yml | 6 + .github/workflows/ci.yml | 3 + .github/workflows/publish-cli.yml | 18 + .gitignore | 1 + CLAUDE.md | 17 +- apps/cli/CLAUDE.md | 2 + apps/cli/package.json | 11 +- apps/cli/src/commands/install.ts | 372 ++++++++++++++ apps/cli/src/index.ts | 17 + apps/desktop/dev.ts | 101 ++-- apps/desktop/electron-builder.yml | 6 + apps/desktop/main.ts | 33 +- apps/desktop/package.json | 2 +- apps/desktop/preload.ts | 13 +- apps/web/package.json | 3 +- .../__tests__/mcp-sync-state-active.test.ts | 19 + apps/web/server/api/ai/agent.ts | 260 +++++++++- apps/web/server/api/ai/connect-acp.ts | 30 ++ apps/web/server/api/mcp/document.post.ts | 143 +++++- .../server/utils/acp-connection-manager.ts | 79 +++ apps/web/server/utils/agent-sessions.ts | 55 ++- apps/web/server/utils/mcp-sync-state.ts | 11 +- .../skia/__tests__/skia-interaction.test.ts | 106 +++- apps/web/src/canvas/skia/skia-interaction.ts | 21 +- .../src/components/panels/ai-chat-handlers.ts | 144 +++++- .../src/components/panels/ai-chat-input.tsx | 20 +- .../panels/ai-chat-model-selector.tsx | 11 +- .../src/components/panels/ai-chat-panel.tsx | 27 +- .../components/shared/acp-agent-settings.tsx | 461 ++++++++++++++++++ .../shared/agent-settings-providers-tab.tsx | 4 + apps/web/src/hooks/use-file-drop.ts | 7 +- apps/web/src/hooks/use-mcp-sync.ts | 74 ++- apps/web/src/i18n/locales/de.ts | 24 + apps/web/src/i18n/locales/en.ts | 24 + apps/web/src/i18n/locales/es.ts | 25 + apps/web/src/i18n/locales/fr.ts | 25 + apps/web/src/i18n/locales/hi.ts | 24 + apps/web/src/i18n/locales/id.ts | 24 + apps/web/src/i18n/locales/ja.ts | 24 + apps/web/src/i18n/locales/ko.ts | 24 + apps/web/src/i18n/locales/pt.ts | 24 + apps/web/src/i18n/locales/ru.ts | 24 + apps/web/src/i18n/locales/th.ts | 24 + apps/web/src/i18n/locales/tr.ts | 24 + apps/web/src/i18n/locales/vi.ts | 24 + apps/web/src/i18n/locales/zh-tw.ts | 24 + apps/web/src/i18n/locales/zh.ts | 24 + .../builtin-provider-presets.test.ts | 89 ++++ apps/web/src/lib/builtin-provider-presets.ts | 81 ++- .../src/services/ai/agent-tool-executor.ts | 103 +++- apps/web/src/services/ai/agent-tools.ts | 192 ++++++-- apps/web/src/services/ai/codegen-prompts.ts | 32 +- apps/web/src/services/ai/mobile-status-bar.ts | 8 +- apps/web/src/stores/agent-settings-store.ts | 51 ++ apps/web/src/types/agent-settings.ts | 15 +- apps/web/src/types/electron.d.ts | 2 + bun.lock | 46 +- package.json | 11 +- packages/agent-native | 2 +- packages/pen-acp/package.json | 23 + packages/pen-acp/src/client.ts | 144 ++++++ packages/pen-acp/src/event-adapter.ts | 70 +++ packages/pen-acp/src/index.ts | 3 + packages/pen-acp/src/types.ts | 41 ++ packages/pen-ai-skills/LICENSE | 21 + packages/pen-ai-skills/README.md | 181 +++++++ packages/pen-ai-skills/package.json | 16 +- packages/pen-core/CLAUDE.md | 40 ++ packages/pen-core/LICENSE | 21 + packages/pen-core/README.md | 187 ++++++- packages/pen-core/package.json | 16 +- packages/pen-engine/CLAUDE.md | 45 ++ packages/pen-engine/LICENSE | 21 + packages/pen-engine/README.md | 206 ++++++++ packages/pen-engine/package.json | 16 +- packages/pen-figma/CLAUDE.md | 37 ++ packages/pen-figma/LICENSE | 21 + packages/pen-figma/README.md | 132 ++++- packages/pen-figma/package.json | 16 +- packages/pen-mcp/CLAUDE.md | 78 +++ packages/pen-mcp/LICENSE | 21 + packages/pen-mcp/README.md | 160 ++++++ packages/pen-mcp/package.json | 16 +- packages/pen-mcp/src/document-manager.ts | 10 +- packages/pen-mcp/src/routes/design-routes.ts | 2 +- .../pen-mcp/src/routes/document-routes.ts | 7 +- packages/pen-mcp/src/routes/node-routes.ts | 6 +- packages/pen-mcp/src/tools/batch-design.ts | 190 +++++++- packages/pen-mcp/src/tools/design-prompt.ts | 15 +- packages/pen-mcp/src/tools/open-document.ts | 8 +- packages/pen-react/CLAUDE.md | 73 +++ packages/pen-react/LICENSE | 21 + packages/pen-react/README.md | 196 ++++++++ packages/pen-react/package.json | 16 +- packages/pen-renderer/CLAUDE.md | 31 ++ packages/pen-renderer/LICENSE | 21 + packages/pen-renderer/README.md | 172 ++++++- packages/pen-renderer/package.json | 16 +- packages/pen-sdk/CLAUDE.md | 29 ++ packages/pen-sdk/LICENSE | 21 + packages/pen-sdk/README.md | 141 +++++- packages/pen-sdk/package.json | 16 +- packages/pen-types/CLAUDE.md | 27 + packages/pen-types/LICENSE | 21 + packages/pen-types/README.md | 2 +- packages/pen-types/package.json | 16 +- scripts/bundle-skill.ts | 56 +++ 107 files changed, 5379 insertions(+), 337 deletions(-) create mode 100644 apps/cli/src/commands/install.ts create mode 100644 apps/web/server/api/ai/connect-acp.ts create mode 100644 apps/web/server/utils/acp-connection-manager.ts create mode 100644 apps/web/src/components/shared/acp-agent-settings.tsx create mode 100644 packages/pen-acp/package.json create mode 100644 packages/pen-acp/src/client.ts create mode 100644 packages/pen-acp/src/event-adapter.ts create mode 100644 packages/pen-acp/src/index.ts create mode 100644 packages/pen-acp/src/types.ts create mode 100644 packages/pen-ai-skills/LICENSE create mode 100644 packages/pen-ai-skills/README.md create mode 100644 packages/pen-core/CLAUDE.md create mode 100644 packages/pen-core/LICENSE create mode 100644 packages/pen-engine/CLAUDE.md create mode 100644 packages/pen-engine/LICENSE create mode 100644 packages/pen-engine/README.md create mode 100644 packages/pen-figma/CLAUDE.md create mode 100644 packages/pen-figma/LICENSE create mode 100644 packages/pen-mcp/CLAUDE.md create mode 100644 packages/pen-mcp/LICENSE create mode 100644 packages/pen-mcp/README.md create mode 100644 packages/pen-react/CLAUDE.md create mode 100644 packages/pen-react/LICENSE create mode 100644 packages/pen-react/README.md create mode 100644 packages/pen-renderer/CLAUDE.md create mode 100644 packages/pen-renderer/LICENSE create mode 100644 packages/pen-sdk/CLAUDE.md create mode 100644 packages/pen-sdk/LICENSE create mode 100644 packages/pen-types/CLAUDE.md create mode 100644 packages/pen-types/LICENSE create mode 100644 scripts/bundle-skill.ts 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]')} + /> + + + +