mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.7.1 (#102)
* fix(desktop,web): rebuild Electron dev sync + bitmap dragging fix on v0.7.1 (#99) Re-appliesb046a0dfrom 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:b046a0dSupersedes: #97 (closed, head branch deleted) Co-authored-by: Rais <vdcoolzi@gmail.com> * 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 <vdcoolzi@gmail.com>
This commit is contained in:
parent
0e02281982
commit
b51d069ea6
107 changed files with 5379 additions and 337 deletions
6
.github/workflows/build-electron.yml
vendored
6
.github/workflows/build-electron.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
18
.github/workflows/publish-cli.yml
vendored
18
.github/workflows/publish-cli.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,6 +17,7 @@ docs/
|
|||
# Build outputs
|
||||
out/
|
||||
dist/
|
||||
apps/cli/src/commands/skill-bundle.json
|
||||
dist-ssr/
|
||||
electron-dist/
|
||||
dist-electron/
|
||||
|
|
|
|||
17
CLAUDE.md
17
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <path>` (target .op file), `--page <id>` (target page)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
372
apps/cli/src/commands/install.ts
Normal file
372
apps/cli/src/commands/install.ts
Normal file
|
|
@ -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 <name>` 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<string, string>;
|
||||
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<Target, () => 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<Target, () => 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 <name> 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.');
|
||||
}
|
||||
|
|
@ -101,6 +101,11 @@ Import:
|
|||
op import:svg <file.svg> Import SVG file
|
||||
op import:figma <file.fig> 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<void> {
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@zseven-w/desktop",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UpdaterState>;
|
||||
checkForUpdates: () => Promise<UpdaterState>;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, AuthLevel> = {
|
||||
batch_get: 'read',
|
||||
|
|
@ -84,7 +89,12 @@ const ROLE_SKILL_PHASE: Record<string, Phase> = {
|
|||
const ROLE_TOOL_INSTRUCTIONS: Record<string, string> = {
|
||||
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<AgentBody>(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<typeof conn.connection.newSession>[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,
|
||||
|
|
|
|||
30
apps/web/server/api/ai/connect-acp.ts
Normal file
30
apps/web/server/api/ai/connect-acp.ts
Normal file
|
|
@ -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}` });
|
||||
});
|
||||
|
|
@ -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<PostBody>(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<PostBody>(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 };
|
||||
});
|
||||
|
|
|
|||
79
apps/web/server/utils/acp-connection-manager.ts
Normal file
79
apps/web/server/utils/acp-connection-manager.ts
Normal file
|
|
@ -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<string, AcpConnectionState>;
|
||||
};
|
||||
const connections: Map<string, AcpConnectionState> =
|
||||
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<AcpConnectResult> {
|
||||
// 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
toolOwners: Map<string, string>;
|
||||
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<string, AgentSession>();
|
||||
|
||||
/** Mark a session as active so long-running external tool callbacks are not expired. */
|
||||
|
|
@ -68,6 +104,7 @@ export function touchSession(session: Pick<AgentSession, 'lastActivity'>, 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,13 +65,20 @@ export function unregisterSSEClient(id: string): void {
|
|||
}
|
||||
|
||||
function broadcast(payload: Record<string, unknown>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {
|
||||
|
|
@ -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<string, AgentIdentity>();
|
||||
// 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<string, string>();
|
||||
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<AgentEvent, { type: 'tool_call' }>)
|
||||
.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 },
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<ProvIcon className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<Key size={12} className="shrink-0 text-muted-foreground" />
|
||||
);
|
||||
}
|
||||
return <Key size={12} className="shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
if (model.startsWith('acp:')) {
|
||||
return <Plug size={12} className="shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
const currentProvider = modelGroups.find((g) =>
|
||||
g.models.some((m) => m.value === model),
|
||||
)?.provider;
|
||||
if (currentProvider) {
|
||||
const ProvIcon = PROVIDER_ICON[currentProvider];
|
||||
return <ProvIcon className="w-3.5 h-3.5 shrink-0" />;
|
||||
const ProvIcon = PROVIDER_ICON[currentProvider as keyof typeof PROVIDER_ICON];
|
||||
return ProvIcon ? <ProvIcon className="w-3.5 h-3.5 shrink-0" /> : null;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div key={groupKey}>
|
||||
<div className="flex items-center gap-1.5 px-3 pt-2.5 pb-1">
|
||||
{isBuiltinGroup ? (
|
||||
<Key size={10} className="text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
) : isAcpGroup ? (
|
||||
<Plug size={10} className="text-muted-foreground shrink-0" />
|
||||
) : GIcon ? (
|
||||
<GIcon className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
) : null}
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{group.providerName}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
461
apps/web/src/components/shared/acp-agent-settings.tsx
Normal file
461
apps/web/src/components/shared/acp-agent-settings.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70 pl-0.5">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>).electronAPI
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse KEY=VALUE lines into a record */
|
||||
function parseEnvText(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
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, string>): 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<AcpAgentConfig, 'id'>) => 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 (
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-secondary/30 border-b border-border">
|
||||
<div className="h-5 w-5 rounded-md bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles size={11} className="text-primary" />
|
||||
</div>
|
||||
<span className="text-[12px] font-medium text-foreground">
|
||||
{initial ? t('common.save') : t('acp.addAgent')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3.5">
|
||||
{/* Display Name */}
|
||||
<Field label={t('acp.displayName')}>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t('acp.displayNamePlaceholder')}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Connection Type toggle */}
|
||||
<Field label={t('acp.connectionType')}>
|
||||
<div className="flex gap-1">
|
||||
{electron && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectionType('local')}
|
||||
className={cn(
|
||||
'flex-1 h-7 text-[11px] rounded-md border transition-all font-medium flex items-center justify-center gap-1',
|
||||
connectionType === 'local'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background text-muted-foreground border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Terminal size={11} />
|
||||
{t('acp.local')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectionType('remote')}
|
||||
className={cn(
|
||||
'flex-1 h-7 text-[11px] rounded-md border transition-all font-medium flex items-center justify-center gap-1',
|
||||
connectionType === 'remote'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background text-muted-foreground border-input hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Globe size={11} />
|
||||
{t('acp.remote')}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Local-mode fields */}
|
||||
{connectionType === 'local' && (
|
||||
<>
|
||||
<Field label={t('acp.command')}>
|
||||
<input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder={t('acp.commandPlaceholder')}
|
||||
className={cn(inputClass, 'font-mono')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('acp.args')}>
|
||||
<input
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
placeholder={t('acp.argsPlaceholder')}
|
||||
className={cn(inputClass, 'font-mono text-[11px]')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('acp.env')}>
|
||||
<textarea
|
||||
value={envText}
|
||||
onChange={(e) => setEnvText(e.target.value)}
|
||||
placeholder={t('acp.envPlaceholder')}
|
||||
rows={3}
|
||||
className={cn(
|
||||
inputClass,
|
||||
'h-auto py-1.5 resize-none font-mono text-[11px] leading-relaxed',
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remote-mode fields */}
|
||||
{connectionType === 'remote' && (
|
||||
<Field label={t('acp.url')}>
|
||||
<div className="relative">
|
||||
<Globe
|
||||
size={12}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/40"
|
||||
/>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={t('acp.urlPlaceholder')}
|
||||
className={cn(inputClass, 'pl-7 font-mono text-[11px]')}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-1.5 border-t border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
className="h-7 px-3 text-[11px] rounded-md"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="h-7 px-4 text-[11px] rounded-md"
|
||||
>
|
||||
{initial ? t('common.save') : t('acp.addAgent')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- AcpAgentCard ---------- */
|
||||
function AcpAgentCard({ agent }: { agent: AcpAgentConfig }) {
|
||||
const { t } = useTranslation();
|
||||
const update = useAgentSettingsStore((s) => s.updateAcpAgent);
|
||||
const remove = useAgentSettingsStore((s) => s.removeAcpAgent);
|
||||
const setStatus = useAgentSettingsStore((s) => s.setAcpConnectionStatus);
|
||||
const persist = useAgentSettingsStore((s) => s.persist);
|
||||
const connectionStatus = useAgentSettingsStore((s) => s.acpConnectionStatus[agent.id]);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
const isConnected = connectionStatus?.isConnected ?? false;
|
||||
const agentInfo = connectionStatus?.agentInfo;
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
setConnecting(true);
|
||||
try {
|
||||
const res = await fetch('/api/ai/connect-acp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'connect', agentId: agent.id, config: agent }),
|
||||
});
|
||||
const data = res.ok ? await res.json() : null;
|
||||
if (data?.connected) {
|
||||
setStatus(agent.id, {
|
||||
isConnected: true,
|
||||
agentInfo: data.agentInfo,
|
||||
});
|
||||
} else {
|
||||
const msg = data?.error ?? `HTTP ${res.status}`;
|
||||
console.error('[acp] connect failed:', msg);
|
||||
alert(`ACP connect failed: ${msg}`);
|
||||
setStatus(agent.id, { isConnected: false });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[acp] connect error:', err);
|
||||
setStatus(agent.id, { isConnected: false });
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [agent, setStatus]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setConnecting(true);
|
||||
try {
|
||||
await fetch('/api/ai/connect-acp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'disconnect', agentId: agent.id }),
|
||||
});
|
||||
setStatus(agent.id, { isConnected: false });
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [agent.id, setStatus]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
remove(agent.id);
|
||||
persist();
|
||||
}, [agent.id, remove, persist]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(data: Omit<AcpAgentConfig, 'id'>) => {
|
||||
update(agent.id, data);
|
||||
persist();
|
||||
setEditing(false);
|
||||
},
|
||||
[agent.id, update, persist],
|
||||
);
|
||||
|
||||
if (editing) {
|
||||
return <AcpAgentForm initial={agent} onSave={handleSave} onCancel={() => setEditing(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3.5 py-2.5 rounded-lg border transition-colors',
|
||||
agent.enabled
|
||||
? 'bg-secondary/30 border-border'
|
||||
: 'border-transparent hover:bg-secondary/20',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors',
|
||||
agent.enabled
|
||||
? 'bg-foreground/8 text-foreground'
|
||||
: 'bg-secondary text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{agent.connectionType === 'local' ? <Terminal size={18} /> : <Globe size={18} />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[13px] font-medium text-foreground leading-tight">
|
||||
{agent.displayName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded-full',
|
||||
agent.connectionType === 'local'
|
||||
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{agent.connectionType === 'local' ? t('acp.local') : t('acp.remote')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||
isConnected ? 'bg-green-500' : 'bg-muted-foreground/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground leading-tight">
|
||||
{isConnected
|
||||
? agentInfo
|
||||
? `${agentInfo.name}${agentInfo.version ? ` v${agentInfo.version}` : ''}`
|
||||
: t('acp.connected')
|
||||
: t('acp.notConnected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={isConnected ? handleDisconnect : handleConnect}
|
||||
disabled={connecting}
|
||||
className="h-6 px-2 text-[10px] rounded-md gap-1"
|
||||
>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Unplug size={11} />
|
||||
{t('acp.disconnect')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plug size={11} />
|
||||
{t('acp.connect')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setEditing(true)}
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleRemove}
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- AcpAgentSection ---------- */
|
||||
export function AcpAgentSection() {
|
||||
const { t } = useTranslation();
|
||||
const acpAgents = useAgentSettingsStore((s) => s.acpAgents);
|
||||
const addAcpAgent = useAgentSettingsStore((s) => s.addAcpAgent);
|
||||
const persist = useAgentSettingsStore((s) => s.persist);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(data: Omit<AcpAgentConfig, 'id'>) => {
|
||||
addAcpAgent(data);
|
||||
persist();
|
||||
setShowForm(false);
|
||||
},
|
||||
[addAcpAgent, persist],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">{t('acp.title')}</h3>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="text-[11px] text-primary hover:text-primary/80 flex items-center gap-1 transition-colors font-medium"
|
||||
>
|
||||
<Plus size={12} /> {t('acp.addAgent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('acp.description')}</p>
|
||||
{showForm && <AcpAgentForm onSave={handleAdd} onCancel={() => setShowForm(false)} />}
|
||||
{acpAgents.map((agent) => (
|
||||
<AcpAgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
{!showForm && acpAgents.length === 0 && (
|
||||
<div className="text-center py-6 text-[11px] text-muted-foreground">{t('acp.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { useAgentSettingsStore } from '@/stores/agent-settings-store';
|
||||
import { BuiltinProvidersSection } from './builtin-provider-settings';
|
||||
import { AcpAgentSection } from './acp-agent-settings';
|
||||
import type { AIProviderType, GroupedModel } from '@/types/agent-settings';
|
||||
import ClaudeLogo from '@/components/icons/claude-logo';
|
||||
import OpenAILogo from '@/components/icons/openai-logo';
|
||||
|
|
@ -312,6 +313,9 @@ export function ProvidersTab() {
|
|||
<div className="mb-6">
|
||||
<BuiltinProvidersSection />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<AcpAgentSection />
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-4">{t('settings.agents')}</h3>
|
||||
<div className="space-y-1">
|
||||
<ProviderCard type="anthropic" />
|
||||
|
|
|
|||
|
|
@ -75,7 +75,12 @@ export function useFileDrop() {
|
|||
const result = await parseDroppedFile(file);
|
||||
if (!result) return;
|
||||
|
||||
useDocumentStore.getState().loadDocument(result.doc, result.fileName);
|
||||
// In Electron, resolve the absolute filesystem path so the recent files
|
||||
// entry is clickable later. In a plain browser this returns null.
|
||||
const filePath =
|
||||
(typeof window !== 'undefined' && window.electronAPI?.getPathForFile?.(file)) || null;
|
||||
|
||||
useDocumentStore.getState().loadDocument(result.doc, result.fileName, null, filePath);
|
||||
|
||||
// Let the canvas sync, then zoom to fit
|
||||
const { zoomToFitContent } = await import('@/canvas/skia-engine-ref');
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ const PUSH_DEBOUNCE_MS = 2000;
|
|||
const SELECTION_DEBOUNCE_MS = 300;
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
const SYNC_MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
let oversizeSyncWarned = false;
|
||||
|
||||
async function handleScreenshotRequest(
|
||||
req: {
|
||||
|
|
@ -92,13 +95,38 @@ function getBaseUrl(): string {
|
|||
return window.location.origin;
|
||||
}
|
||||
|
||||
function pushDocumentToServer(clientId: string | null) {
|
||||
async function pushDocumentToServer(clientId: string | null) {
|
||||
const doc = useDocumentStore.getState().document;
|
||||
fetch(`${getBaseUrl()}/api/mcp/document`, {
|
||||
const body = JSON.stringify({ document: doc, sourceClientId: clientId });
|
||||
const bodyBytes = new TextEncoder().encode(body).byteLength;
|
||||
|
||||
if (bodyBytes > SYNC_MAX_BODY_BYTES) {
|
||||
if (!oversizeSyncWarned) {
|
||||
oversizeSyncWarned = true;
|
||||
console.warn(
|
||||
`[mcp-sync] Skip oversized document push: ${(bodyBytes / (1024 * 1024)).toFixed(
|
||||
2,
|
||||
)}MiB > ${(SYNC_MAX_BODY_BYTES / (1024 * 1024)).toFixed(2)}MiB`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
oversizeSyncWarned = false;
|
||||
|
||||
await fetch(`${getBaseUrl()}/api/mcp/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document: doc, sourceClientId: clientId }),
|
||||
}).catch(() => {});
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openpencil-client-id': clientId ?? 'renderer:unknown',
|
||||
'x-openpencil-body-bytes': String(bodyBytes),
|
||||
},
|
||||
// Keep smaller requests alive through page transitions and HMR churn.
|
||||
...(bodyBytes <= 60_000 ? { keepalive: true } : {}),
|
||||
// Large local sync payloads need a wider timeout budget than the fetch default.
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,6 +137,9 @@ function pushDocumentToServer(clientId: string | null) {
|
|||
export function useMcpSync() {
|
||||
const clientIdRef = useRef<string | null>(null);
|
||||
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pushInFlightRef = useRef(false);
|
||||
const pushQueuedRef = useRef(false);
|
||||
const queuedClientIdRef = useRef<string | null>(null);
|
||||
// Skip debounce pushes briefly after applying an external document.
|
||||
// Use a timestamp instead of a boolean so cascading setState calls
|
||||
// (e.g. canvas sync page switch handler) are also suppressed.
|
||||
|
|
@ -121,6 +152,30 @@ export function useMcpSync() {
|
|||
let disposed = false;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
async function flushDocumentPush(clientId: string | null) {
|
||||
if (disposed) return;
|
||||
if (pushInFlightRef.current) {
|
||||
pushQueuedRef.current = true;
|
||||
queuedClientIdRef.current = clientId;
|
||||
return;
|
||||
}
|
||||
|
||||
pushInFlightRef.current = true;
|
||||
try {
|
||||
await pushDocumentToServer(clientId);
|
||||
} catch {
|
||||
// MCP sync is a best-effort enhancement and should not interrupt editing.
|
||||
} finally {
|
||||
pushInFlightRef.current = false;
|
||||
if (pushQueuedRef.current && !disposed) {
|
||||
const nextClientId = queuedClientIdRef.current;
|
||||
pushQueuedRef.current = false;
|
||||
queuedClientIdRef.current = null;
|
||||
void flushDocumentPush(nextClientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Focus / visibility ping: keep lastActiveClientId accurate ----
|
||||
const sendActivePing = () => {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
|
|
@ -150,7 +205,7 @@ export function useMcpSync() {
|
|||
if (data.type === 'client:id') {
|
||||
clientIdRef.current = data.clientId;
|
||||
// Push current document so MCP can read it immediately
|
||||
pushDocumentToServer(data.clientId);
|
||||
void flushDocumentPush(data.clientId);
|
||||
// Announce this tab as the active one
|
||||
sendActivePing();
|
||||
} else if (data.type === 'document:update' || data.type === 'document:init') {
|
||||
|
|
@ -203,17 +258,20 @@ export function useMcpSync() {
|
|||
// On loadDocument/newDocument (isDirty transitions to false), push
|
||||
// immediately so the server cache is replaced without waiting 2s.
|
||||
const unsubDoc = useDocumentStore.subscribe((state, prevState) => {
|
||||
const documentChanged = state.document !== prevState.document;
|
||||
const dirtyChanged = state.isDirty !== prevState.isDirty;
|
||||
if (!documentChanged && !dirtyChanged) return;
|
||||
if (Date.now() < skipPushUntilRef.current) return;
|
||||
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
||||
|
||||
const isLoadEvent = !state.isDirty && prevState.isDirty !== state.isDirty;
|
||||
if (isLoadEvent) {
|
||||
pushDocumentToServer(clientIdRef.current);
|
||||
void flushDocumentPush(clientIdRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
pushTimerRef.current = setTimeout(() => {
|
||||
pushDocumentToServer(clientIdRef.current);
|
||||
void flushDocumentPush(clientIdRef.current);
|
||||
}, PUSH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -776,6 +776,30 @@ const de: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Design-Modell',
|
||||
'builtin.teamSelectModel': 'Keins (Einzelagent)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Externe ACP-kompatible Agent verbinden.',
|
||||
'acp.addAgent': 'Agent hinzufügen',
|
||||
'acp.empty': 'Keine ACP Agent konfiguriert.',
|
||||
'acp.displayName': 'Anzeigename',
|
||||
'acp.displayNamePlaceholder': 'z.B. Mein Design-Agent',
|
||||
'acp.connectionType': 'Verbindungstyp',
|
||||
'acp.local': 'Lokal',
|
||||
'acp.remote': 'Remote',
|
||||
'acp.command': 'Befehl',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Argumente',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Umgebungsvariablen',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (eine pro Zeile)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Verbunden',
|
||||
'acp.notConnected': 'Nicht verbunden',
|
||||
'acp.connect': 'Verbinden',
|
||||
'acp.disconnect': 'Trennen',
|
||||
'acp.localDesktopOnly': 'Lokale Agent sind nur in der Desktop-App verfügbar.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Aus Figma importieren',
|
||||
'figma.dropFile': '.fig-Datei hier ablegen',
|
||||
|
|
|
|||
|
|
@ -765,6 +765,30 @@ const en = {
|
|||
'builtin.teamDesignModel': 'Design Model',
|
||||
'builtin.teamSelectModel': 'None (single agent)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agents',
|
||||
'acp.description': 'Connect external ACP-compatible agents.',
|
||||
'acp.addAgent': 'Add Agent',
|
||||
'acp.empty': 'No ACP agents configured yet.',
|
||||
'acp.displayName': 'Display Name',
|
||||
'acp.displayNamePlaceholder': 'e.g. My Design Agent',
|
||||
'acp.connectionType': 'Connection Type',
|
||||
'acp.local': 'Local',
|
||||
'acp.remote': 'Remote',
|
||||
'acp.command': 'Command',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Arguments',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Environment Variables',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (one per line)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Connected',
|
||||
'acp.notConnected': 'Not connected',
|
||||
'acp.connect': 'Connect',
|
||||
'acp.disconnect': 'Disconnect',
|
||||
'acp.localDesktopOnly': 'Local agents are only available in the desktop app.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Import from Figma',
|
||||
'figma.dropFile': 'Drop a .fig file here',
|
||||
|
|
|
|||
|
|
@ -777,6 +777,31 @@ const es: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Modelo de diseño',
|
||||
'builtin.teamSelectModel': 'Ninguno (agente único)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Conectar Agent compatibles con ACP.',
|
||||
'acp.addAgent': 'Agregar Agent',
|
||||
'acp.empty': 'No hay ACP Agent configurados.',
|
||||
'acp.displayName': 'Nombre',
|
||||
'acp.displayNamePlaceholder': 'ej. Mi Agent de diseño',
|
||||
'acp.connectionType': 'Tipo de conexión',
|
||||
'acp.local': 'Local',
|
||||
'acp.remote': 'Remoto',
|
||||
'acp.command': 'Comando',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Argumentos',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Variables de entorno',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (una por línea)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Conectado',
|
||||
'acp.notConnected': 'No conectado',
|
||||
'acp.connect': 'Conectar',
|
||||
'acp.disconnect': 'Desconectar',
|
||||
'acp.localDesktopOnly':
|
||||
'Los Agent locales solo están disponibles en la aplicación de escritorio.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Importar desde Figma',
|
||||
'figma.dropFile': 'Suelte un archivo .fig aquí',
|
||||
|
|
|
|||
|
|
@ -781,6 +781,31 @@ const fr: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Modèle de design',
|
||||
'builtin.teamSelectModel': 'Aucun (agent unique)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Connecter des Agent ACP externes.',
|
||||
'acp.addAgent': 'Ajouter un Agent',
|
||||
'acp.empty': 'Aucun ACP Agent configuré.',
|
||||
'acp.displayName': 'Nom affiché',
|
||||
'acp.displayNamePlaceholder': 'ex. Mon Agent de design',
|
||||
'acp.connectionType': 'Type de connexion',
|
||||
'acp.local': 'Local',
|
||||
'acp.remote': 'Distant',
|
||||
'acp.command': 'Commande',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Arguments',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': "Variables d'environnement",
|
||||
'acp.envPlaceholder': 'KEY=VALUE (une par ligne)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Connecté',
|
||||
'acp.notConnected': 'Non connecté',
|
||||
'acp.connect': 'Connecter',
|
||||
'acp.disconnect': 'Déconnecter',
|
||||
'acp.localDesktopOnly':
|
||||
"Les Agent locaux sont disponibles uniquement dans l'application de bureau.",
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Importer depuis Figma',
|
||||
'figma.dropFile': 'Déposez un fichier .fig ici',
|
||||
|
|
|
|||
|
|
@ -759,6 +759,30 @@ const hi: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'डिज़ाइन मॉडल',
|
||||
'builtin.teamSelectModel': 'कोई नहीं (एकल एजेंट)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'बाहरी ACP-संगत Agent कनेक्ट करें।',
|
||||
'acp.addAgent': 'Agent जोड़ें',
|
||||
'acp.empty': 'कोई ACP Agent कॉन्फ़िगर नहीं किया गया।',
|
||||
'acp.displayName': 'प्रदर्शन नाम',
|
||||
'acp.displayNamePlaceholder': 'उदा. मेरा डिज़ाइन Agent',
|
||||
'acp.connectionType': 'कनेक्शन प्रकार',
|
||||
'acp.local': 'स्थानीय',
|
||||
'acp.remote': 'रिमोट',
|
||||
'acp.command': 'कमांड',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'आर्ग्यूमेंट',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'पर्यावरण चर',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (प्रति पंक्ति एक)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'कनेक्टेड',
|
||||
'acp.notConnected': 'कनेक्ट नहीं',
|
||||
'acp.connect': 'कनेक्ट करें',
|
||||
'acp.disconnect': 'डिस्कनेक्ट करें',
|
||||
'acp.localDesktopOnly': 'स्थानीय Agent केवल डेस्कटॉप ऐप में उपलब्ध हैं।',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Figma से आयात करें',
|
||||
'figma.dropFile': 'यहाँ .fig फ़ाइल ड्रॉप करें',
|
||||
|
|
|
|||
|
|
@ -768,6 +768,30 @@ const id: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Model Desain',
|
||||
'builtin.teamSelectModel': 'Tidak ada (agen tunggal)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Hubungkan Agent yang kompatibel dengan ACP.',
|
||||
'acp.addAgent': 'Tambah Agent',
|
||||
'acp.empty': 'Belum ada ACP Agent yang dikonfigurasi.',
|
||||
'acp.displayName': 'Nama Tampilan',
|
||||
'acp.displayNamePlaceholder': 'mis. Agent Desain Saya',
|
||||
'acp.connectionType': 'Jenis Koneksi',
|
||||
'acp.local': 'Lokal',
|
||||
'acp.remote': 'Jarak Jauh',
|
||||
'acp.command': 'Perintah',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Argumen',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Variabel Lingkungan',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (satu per baris)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Terhubung',
|
||||
'acp.notConnected': 'Tidak terhubung',
|
||||
'acp.connect': 'Hubungkan',
|
||||
'acp.disconnect': 'Putuskan',
|
||||
'acp.localDesktopOnly': 'Agent lokal hanya tersedia di aplikasi desktop.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Impor dari Figma',
|
||||
'figma.dropFile': 'Letakkan file .fig di sini',
|
||||
|
|
|
|||
|
|
@ -770,6 +770,30 @@ const ja: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'デザインモデル',
|
||||
'builtin.teamSelectModel': 'なし(シングルエージェント)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': '外部の ACP 互換 Agent を接続します。',
|
||||
'acp.addAgent': 'Agent を追加',
|
||||
'acp.empty': 'ACP Agent はまだ設定されていません。',
|
||||
'acp.displayName': '表示名',
|
||||
'acp.displayNamePlaceholder': '例:デザイン Agent',
|
||||
'acp.connectionType': '接続タイプ',
|
||||
'acp.local': 'ローカル',
|
||||
'acp.remote': 'リモート',
|
||||
'acp.command': 'コマンド',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': '引数',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': '環境変数',
|
||||
'acp.envPlaceholder': 'KEY=VALUE(1行に1つ)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': '接続済み',
|
||||
'acp.notConnected': '未接続',
|
||||
'acp.connect': '接続',
|
||||
'acp.disconnect': '切断',
|
||||
'acp.localDesktopOnly': 'ローカル Agent はデスクトップアプリでのみ使用できます。',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Figma からインポート',
|
||||
'figma.dropFile': '.fig ファイルをここにドロップ',
|
||||
|
|
|
|||
|
|
@ -763,6 +763,30 @@ const ko: TranslationKeys = {
|
|||
'builtin.teamDesignModel': '디자인 모델',
|
||||
'builtin.teamSelectModel': '없음 (단일 에이전트)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': '외부 ACP 호환 Agent를 연결합니다.',
|
||||
'acp.addAgent': 'Agent 추가',
|
||||
'acp.empty': '구성된 ACP Agent가 없습니다.',
|
||||
'acp.displayName': '표시 이름',
|
||||
'acp.displayNamePlaceholder': '예: 내 디자인 Agent',
|
||||
'acp.connectionType': '연결 유형',
|
||||
'acp.local': '로컬',
|
||||
'acp.remote': '원격',
|
||||
'acp.command': '명령어',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': '인수',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': '환경 변수',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (줄당 하나)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': '연결됨',
|
||||
'acp.notConnected': '연결 안됨',
|
||||
'acp.connect': '연결',
|
||||
'acp.disconnect': '연결 해제',
|
||||
'acp.localDesktopOnly': '로컬 Agent는 데스크톱 앱에서만 사용할 수 있습니다.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Figma에서 가져오기',
|
||||
'figma.dropFile': '.fig 파일을 여기에 놓으세요',
|
||||
|
|
|
|||
|
|
@ -774,6 +774,30 @@ const pt: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Modelo de design',
|
||||
'builtin.teamSelectModel': 'Nenhum (agente único)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Conectar Agent compatíveis com ACP.',
|
||||
'acp.addAgent': 'Adicionar Agent',
|
||||
'acp.empty': 'Nenhum ACP Agent configurado.',
|
||||
'acp.displayName': 'Nome de exibição',
|
||||
'acp.displayNamePlaceholder': 'ex. Meu Agent de design',
|
||||
'acp.connectionType': 'Tipo de conexão',
|
||||
'acp.local': 'Local',
|
||||
'acp.remote': 'Remoto',
|
||||
'acp.command': 'Comando',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Argumentos',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Variáveis de ambiente',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (uma por linha)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Conectado',
|
||||
'acp.notConnected': 'Não conectado',
|
||||
'acp.connect': 'Conectar',
|
||||
'acp.disconnect': 'Desconectar',
|
||||
'acp.localDesktopOnly': 'Agent locais estão disponíveis apenas no aplicativo desktop.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Importar do Figma',
|
||||
'figma.dropFile': 'Solte um arquivo .fig aqui',
|
||||
|
|
|
|||
|
|
@ -771,6 +771,30 @@ const ru: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Модель дизайна',
|
||||
'builtin.teamSelectModel': 'Нет (один агент)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Подключение внешних ACP-совместимых Agent.',
|
||||
'acp.addAgent': 'Добавить Agent',
|
||||
'acp.empty': 'Нет настроенных ACP Agent.',
|
||||
'acp.displayName': 'Отображаемое имя',
|
||||
'acp.displayNamePlaceholder': 'напр. Мой Agent дизайна',
|
||||
'acp.connectionType': 'Тип подключения',
|
||||
'acp.local': 'Локальный',
|
||||
'acp.remote': 'Удалённый',
|
||||
'acp.command': 'Команда',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Аргументы',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Переменные среды',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (по одной на строку)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Подключён',
|
||||
'acp.notConnected': 'Не подключён',
|
||||
'acp.connect': 'Подключить',
|
||||
'acp.disconnect': 'Отключить',
|
||||
'acp.localDesktopOnly': 'Локальные Agent доступны только в настольном приложении.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Импорт из Figma',
|
||||
'figma.dropFile': 'Перетащите файл .fig сюда',
|
||||
|
|
|
|||
|
|
@ -755,6 +755,30 @@ const th: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'โมเดลออกแบบ',
|
||||
'builtin.teamSelectModel': 'ไม่มี (เอเจนต์เดี่ยว)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'เชื่อมต่อ Agent ที่รองรับ ACP ภายนอก',
|
||||
'acp.addAgent': 'เพิ่ม Agent',
|
||||
'acp.empty': 'ยังไม่ได้กำหนดค่า ACP Agent',
|
||||
'acp.displayName': 'ชื่อที่แสดง',
|
||||
'acp.displayNamePlaceholder': 'เช่น Agent ออกแบบของฉัน',
|
||||
'acp.connectionType': 'ประเภทการเชื่อมต่อ',
|
||||
'acp.local': 'ภายในเครื่อง',
|
||||
'acp.remote': 'ระยะไกล',
|
||||
'acp.command': 'คำสั่ง',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'อาร์กิวเมนต์',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'ตัวแปรสภาพแวดล้อม',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (บรรทัดละหนึ่ง)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'เชื่อมต่อแล้ว',
|
||||
'acp.notConnected': 'ไม่ได้เชื่อมต่อ',
|
||||
'acp.connect': 'เชื่อมต่อ',
|
||||
'acp.disconnect': 'ยกเลิกการเชื่อมต่อ',
|
||||
'acp.localDesktopOnly': 'Agent ภายในเครื่องใช้ได้เฉพาะในแอปเดสก์ท็อป',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'นำเข้าจาก Figma',
|
||||
'figma.dropFile': 'วางไฟล์ .fig ที่นี่',
|
||||
|
|
|
|||
|
|
@ -772,6 +772,30 @@ const tr: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Tasarım Modeli',
|
||||
'builtin.teamSelectModel': 'Yok (tek ajan)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Harici ACP uyumlu Agent bağlayın.',
|
||||
'acp.addAgent': 'Agent Ekle',
|
||||
'acp.empty': 'Yapılandırılmış ACP Agent yok.',
|
||||
'acp.displayName': 'Görünen Ad',
|
||||
'acp.displayNamePlaceholder': "örn. Tasarım Agent'ım",
|
||||
'acp.connectionType': 'Bağlantı Türü',
|
||||
'acp.local': 'Yerel',
|
||||
'acp.remote': 'Uzak',
|
||||
'acp.command': 'Komut',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Argümanlar',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Ortam Değişkenleri',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (satır başına bir)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Bağlı',
|
||||
'acp.notConnected': 'Bağlı değil',
|
||||
'acp.connect': 'Bağlan',
|
||||
'acp.disconnect': 'Bağlantıyı Kes',
|
||||
'acp.localDesktopOnly': 'Yerel Agent yalnızca masaüstü uygulamasında kullanılabilir.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': "Figma'dan İçe Aktar",
|
||||
'figma.dropFile': 'Bir .fig dosyasını buraya bırakın',
|
||||
|
|
|
|||
|
|
@ -765,6 +765,30 @@ const vi: TranslationKeys = {
|
|||
'builtin.teamDesignModel': 'Mô hình thiết kế',
|
||||
'builtin.teamSelectModel': 'Không (agent đơn)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': 'Kết nối các Agent tương thích ACP bên ngoài.',
|
||||
'acp.addAgent': 'Thêm Agent',
|
||||
'acp.empty': 'Chưa có ACP Agent nào được cấu hình.',
|
||||
'acp.displayName': 'Tên hiển thị',
|
||||
'acp.displayNamePlaceholder': 'vd. Agent thiết kế của tôi',
|
||||
'acp.connectionType': 'Loại kết nối',
|
||||
'acp.local': 'Cục bộ',
|
||||
'acp.remote': 'Từ xa',
|
||||
'acp.command': 'Lệnh',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': 'Đối số',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': 'Biến môi trường',
|
||||
'acp.envPlaceholder': 'KEY=VALUE (mỗi dòng một cặp)',
|
||||
'acp.url': 'URL',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': 'Đã kết nối',
|
||||
'acp.notConnected': 'Chưa kết nối',
|
||||
'acp.connect': 'Kết nối',
|
||||
'acp.disconnect': 'Ngắt kết nối',
|
||||
'acp.localDesktopOnly': 'Agent cục bộ chỉ khả dụng trong ứng dụng desktop.',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': 'Nhập từ Figma',
|
||||
'figma.dropFile': 'Kéo thả tệp .fig vào đây',
|
||||
|
|
|
|||
|
|
@ -753,6 +753,30 @@ const zhTW: TranslationKeys = {
|
|||
'builtin.teamDesignModel': '設計模型',
|
||||
'builtin.teamSelectModel': '無(單 Agent)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': '連接外部 ACP 相容的 Agent。',
|
||||
'acp.addAgent': '新增 Agent',
|
||||
'acp.empty': '尚未設定 ACP Agent。',
|
||||
'acp.displayName': '顯示名稱',
|
||||
'acp.displayNamePlaceholder': '例如:我的設計 Agent',
|
||||
'acp.connectionType': '連線類型',
|
||||
'acp.local': '本機程序',
|
||||
'acp.remote': '遠端服務',
|
||||
'acp.command': '指令',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': '參數',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': '環境變數',
|
||||
'acp.envPlaceholder': 'KEY=VALUE(每行一條)',
|
||||
'acp.url': '網址',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': '已連線',
|
||||
'acp.notConnected': '未連線',
|
||||
'acp.connect': '連線',
|
||||
'acp.disconnect': '中斷連線',
|
||||
'acp.localDesktopOnly': '本機 Agent 僅在桌面應用中可用。',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': '從 Figma 匯入',
|
||||
'figma.dropFile': '將 .fig 檔案拖放至此處',
|
||||
|
|
|
|||
|
|
@ -746,6 +746,30 @@ const zh: TranslationKeys = {
|
|||
'builtin.teamDesignModel': '设计模型',
|
||||
'builtin.teamSelectModel': '无(单 Agent)',
|
||||
|
||||
// ── ACP Agents ──
|
||||
'acp.title': 'ACP Agent',
|
||||
'acp.description': '连接外部 ACP 兼容的 Agent。',
|
||||
'acp.addAgent': '添加 Agent',
|
||||
'acp.empty': '尚未配置 ACP Agent。',
|
||||
'acp.displayName': '显示名称',
|
||||
'acp.displayNamePlaceholder': '例如:我的设计 Agent',
|
||||
'acp.connectionType': '连接类型',
|
||||
'acp.local': '本地进程',
|
||||
'acp.remote': '远程服务',
|
||||
'acp.command': '命令',
|
||||
'acp.commandPlaceholder': '/usr/bin/myagent',
|
||||
'acp.args': '参数',
|
||||
'acp.argsPlaceholder': '--stdio',
|
||||
'acp.env': '环境变量',
|
||||
'acp.envPlaceholder': 'KEY=VALUE(每行一条)',
|
||||
'acp.url': '地址',
|
||||
'acp.urlPlaceholder': 'ws://localhost:8100',
|
||||
'acp.connected': '已连接',
|
||||
'acp.notConnected': '未连接',
|
||||
'acp.connect': '连接',
|
||||
'acp.disconnect': '断开',
|
||||
'acp.localDesktopOnly': '本地 Agent 仅在桌面应用中可用。',
|
||||
|
||||
// ── Figma Import ──
|
||||
'figma.title': '从 Figma 导入',
|
||||
'figma.dropFile': '将 .fig 文件拖放到此处',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,95 @@ describe('builtin provider presets', () => {
|
|||
).toBe('https://api.minimaxi.com/anthropic');
|
||||
});
|
||||
|
||||
it('preserves alternative-format baseURL on canonicalize (e.g. Bailian Coding Plan + Anthropic)', () => {
|
||||
// Repro for the regression where switching a default-OpenAI preset to
|
||||
// its Anthropic alt URL would silently get reset to the OpenAI URL on
|
||||
// save, and the request then went to .../v1/messages → 404.
|
||||
const altFormat = canonicalizeBuiltinProviderConfig({
|
||||
id: 'bp-bailian-coding-anthropic',
|
||||
displayName: 'Bailian Coding Plan',
|
||||
preset: 'bailian-coding',
|
||||
type: 'anthropic',
|
||||
apiKey: 'sk-test',
|
||||
model: 'qwen3-coder-plus',
|
||||
baseURL: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(altFormat.preset).toBe('bailian-coding');
|
||||
expect(altFormat.type).toBe('anthropic');
|
||||
expect(altFormat.baseURL).toBe('https://coding.dashscope.aliyuncs.com/apps/anthropic');
|
||||
});
|
||||
|
||||
it('round-trips the default OpenAI URL on canonicalize for the same preset', () => {
|
||||
// Sanity: we did not break the default-format path while fixing the alt-format one.
|
||||
const defaultFormat = canonicalizeBuiltinProviderConfig({
|
||||
id: 'bp-bailian-coding-openai',
|
||||
displayName: 'Bailian Coding Plan',
|
||||
preset: 'bailian-coding',
|
||||
type: 'openai-compat',
|
||||
apiKey: 'sk-test',
|
||||
model: 'qwen3-coder-plus',
|
||||
baseURL: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(defaultFormat.preset).toBe('bailian-coding');
|
||||
expect(defaultFormat.type).toBe('openai-compat');
|
||||
expect(defaultFormat.baseURL).toBe('https://coding.dashscope.aliyuncs.com/v1');
|
||||
});
|
||||
|
||||
it('infers a unique preset from its alternative-format URL (so reload restores it correctly)', () => {
|
||||
// Previously the URL→preset reverse lookup only knew about default-format
|
||||
// URLs; an alt-format URL would fall through to 'custom' and lose the
|
||||
// preset selection on the next reload.
|
||||
expect(
|
||||
inferBuiltinProviderPreset({
|
||||
type: 'anthropic',
|
||||
baseURL: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
} as any),
|
||||
).toBe('bailian-coding');
|
||||
});
|
||||
|
||||
it('preserves an explicit preset when two presets share the same alt URL', () => {
|
||||
// zhipu and glm-coding both point at https://open.bigmodel.cn/api/anthropic
|
||||
// for their Anthropic alt format. The user's dropdown choice must win.
|
||||
const glmCoding = canonicalizeBuiltinProviderConfig({
|
||||
id: 'bp-glm-coding-anthropic',
|
||||
displayName: 'GLM Coding Plan',
|
||||
preset: 'glm-coding',
|
||||
type: 'anthropic',
|
||||
apiKey: 'key',
|
||||
model: 'glm-4.7',
|
||||
baseURL: 'https://open.bigmodel.cn/api/anthropic',
|
||||
enabled: true,
|
||||
});
|
||||
expect(glmCoding.preset).toBe('glm-coding');
|
||||
expect(glmCoding.baseURL).toBe('https://open.bigmodel.cn/api/anthropic');
|
||||
|
||||
const zhipu = canonicalizeBuiltinProviderConfig({
|
||||
id: 'bp-zhipu-anthropic',
|
||||
displayName: 'Zhipu',
|
||||
preset: 'zhipu',
|
||||
type: 'anthropic',
|
||||
apiKey: 'key',
|
||||
model: 'glm-5',
|
||||
baseURL: 'https://open.bigmodel.cn/api/anthropic',
|
||||
enabled: true,
|
||||
});
|
||||
expect(zhipu.preset).toBe('zhipu');
|
||||
expect(zhipu.baseURL).toBe('https://open.bigmodel.cn/api/anthropic');
|
||||
});
|
||||
|
||||
it('detects global region from an alt-format URL', () => {
|
||||
expect(
|
||||
inferBuiltinProviderRegion({
|
||||
type: 'anthropic',
|
||||
baseURL: 'https://coding-intl.dashscope.aliyuncs.com/apps/anthropic',
|
||||
} as any),
|
||||
).toBe('global');
|
||||
});
|
||||
|
||||
it('prefers a recognized legacy URL over stale built-in preset metadata during migration', () => {
|
||||
const migrated = canonicalizeBuiltinProviderConfig({
|
||||
id: 'bp-stale',
|
||||
|
|
|
|||
|
|
@ -219,10 +219,20 @@ export const BUILTIN_PROVIDER_PRESETS: Record<BuiltinProviderPreset, BuiltinPres
|
|||
|
||||
const PRESET_URL_LOOKUP = Object.entries(BUILTIN_PROVIDER_PRESETS).reduce(
|
||||
(acc, [key, cfg]) => {
|
||||
if (cfg.baseURL) acc[cfg.baseURL] = key as BuiltinProviderPreset;
|
||||
const k = key as BuiltinProviderPreset;
|
||||
if (cfg.baseURL) acc[cfg.baseURL] = k;
|
||||
if (cfg.regions) {
|
||||
acc[cfg.regions.cn.baseURL] = key as BuiltinProviderPreset;
|
||||
acc[cfg.regions.global.baseURL] = key as BuiltinProviderPreset;
|
||||
acc[cfg.regions.cn.baseURL] = k;
|
||||
acc[cfg.regions.global.baseURL] = k;
|
||||
}
|
||||
// Include alternative-format URLs so a saved Anthropic-format config
|
||||
// for an OpenAI-default preset (or vice versa) still maps back to the
|
||||
// correct preset on reload. Without this the canonicalize pass falls
|
||||
// through to inferBuiltinProviderPreset and may collapse to 'custom'.
|
||||
if (cfg.altBaseURL) acc[cfg.altBaseURL] = k;
|
||||
if (cfg.altRegions) {
|
||||
acc[cfg.altRegions.cn] = k;
|
||||
acc[cfg.altRegions.global] = k;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
|
|
@ -255,15 +265,27 @@ function lookupPresetByURL(url?: string): BuiltinProviderPreset | undefined {
|
|||
return PRESET_URL_LOOKUP[normalizedURL] ?? LEGACY_URL_LOOKUP[normalizedURL];
|
||||
}
|
||||
|
||||
/** Whether `url` equals `base`, or `base` followed by a `/v<digits>` segment.
|
||||
* Catches legacy entries where an extra version suffix was appended manually
|
||||
* (`/v1`, `/v3`, etc.) on top of a base that already has its own version. */
|
||||
function urlMatchesIgnoringVersionSuffix(url: string, base: string): boolean {
|
||||
if (url === base) return true;
|
||||
if (!url.startsWith(base + '/')) return false;
|
||||
const tail = url.slice(base.length + 1);
|
||||
return /^v\d+$/.test(tail);
|
||||
}
|
||||
|
||||
function inferRegionFromURL(preset: BuiltinProviderPreset, normalizedURL: string): 'cn' | 'global' {
|
||||
const regions = BUILTIN_PROVIDER_PRESETS[preset].regions;
|
||||
if (!regions) return 'cn';
|
||||
const cfg = BUILTIN_PROVIDER_PRESETS[preset];
|
||||
const regions = cfg.regions;
|
||||
const altRegions = cfg.altRegions;
|
||||
if (!regions && !altRegions) return 'cn';
|
||||
const legacyGlobalURLs = LEGACY_GLOBAL_URL_LOOKUP[preset];
|
||||
return normalizedURL === regions.global.baseURL ||
|
||||
normalizedURL === `${regions.global.baseURL}/v1` ||
|
||||
legacyGlobalURLs?.has(normalizedURL)
|
||||
? 'global'
|
||||
: 'cn';
|
||||
const isGlobal =
|
||||
(regions && urlMatchesIgnoringVersionSuffix(normalizedURL, regions.global.baseURL)) ||
|
||||
(altRegions && urlMatchesIgnoringVersionSuffix(normalizedURL, altRegions.global)) ||
|
||||
legacyGlobalURLs?.has(normalizedURL);
|
||||
return isGlobal ? 'global' : 'cn';
|
||||
}
|
||||
|
||||
export function inferBuiltinProviderPreset(
|
||||
|
|
@ -318,16 +340,49 @@ export function getCanonicalBuiltinBaseURL(
|
|||
return cfg.baseURL;
|
||||
}
|
||||
|
||||
/** Whether the given preset's URL family covers `normalizedURL`. */
|
||||
function presetMatchesURL(preset: BuiltinProviderPreset, normalizedURL: string): boolean {
|
||||
const cfg = BUILTIN_PROVIDER_PRESETS[preset];
|
||||
if (cfg.baseURL && urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.baseURL)) return true;
|
||||
if (cfg.regions) {
|
||||
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.regions.cn.baseURL)) return true;
|
||||
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.regions.global.baseURL)) return true;
|
||||
}
|
||||
if (cfg.altBaseURL && urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.altBaseURL)) return true;
|
||||
if (cfg.altRegions) {
|
||||
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.altRegions.cn)) return true;
|
||||
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.altRegions.global)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canonicalizeBuiltinProviderConfig(
|
||||
config: BuiltinProviderConfig,
|
||||
): BuiltinProviderConfig {
|
||||
if (config.preset === 'custom') return config;
|
||||
|
||||
const preset = lookupPresetByURL(config.baseURL) ?? inferBuiltinProviderPreset(config);
|
||||
const normalizedURL = normalizeURL(config.baseURL);
|
||||
// Respect an explicit preset when its URL family covers the configured
|
||||
// baseURL — this is the path that disambiguates presets sharing the same
|
||||
// alt URL (e.g. zhipu vs glm-coding both point at /api/anthropic). When
|
||||
// the explicit preset is genuinely stale (URL no longer fits the family),
|
||||
// fall back to URL-based lookup so legacy entries can self-heal.
|
||||
// Note: config.preset === 'custom' is already handled by the early return above,
|
||||
// so config.preset here is non-custom (or undefined).
|
||||
const preset =
|
||||
config.preset &&
|
||||
BUILTIN_PROVIDER_PRESETS[config.preset] &&
|
||||
presetMatchesURL(config.preset, normalizedURL)
|
||||
? config.preset
|
||||
: (lookupPresetByURL(config.baseURL) ?? inferBuiltinProviderPreset(config));
|
||||
if (preset === 'custom') return config;
|
||||
|
||||
const region = inferRegionFromURL(preset, normalizeURL(config.baseURL));
|
||||
const canonicalBaseURL = getCanonicalBuiltinBaseURL(preset, region);
|
||||
const region = inferRegionFromURL(preset, normalizedURL);
|
||||
// Pick the canonical URL for the user's chosen API format. Without this
|
||||
// the alternative-format selection (e.g. Anthropic on a preset whose
|
||||
// default is OpenAI-compat) would be silently overwritten on save.
|
||||
const canonicalBaseURL =
|
||||
getBaseURLForFormat(preset, config.type, region) ?? getCanonicalBuiltinBaseURL(preset, region);
|
||||
|
||||
return {
|
||||
...config,
|
||||
|
|
|
|||
|
|
@ -94,10 +94,17 @@ export class AgentToolExecutor {
|
|||
return this.handleBatchInsert(args as { parentId: string | null; nodes: unknown[] });
|
||||
case 'insert_node':
|
||||
return this.handleInsertNode(
|
||||
args as { parent: string | null; data: Record<string, unknown>; pageId?: string },
|
||||
args as {
|
||||
parent?: string | null;
|
||||
after?: string;
|
||||
data: Record<string, unknown>;
|
||||
pageId?: string;
|
||||
},
|
||||
);
|
||||
case 'update_node':
|
||||
return this.handleUpdateNode(args as { id: string; data: Record<string, unknown> });
|
||||
case 'move_node':
|
||||
return this.handleMoveNode(args as { id: string; parent: string; index?: number });
|
||||
case 'delete_node':
|
||||
return this.handleDeleteNode(args as { id: string });
|
||||
case 'find_empty_space':
|
||||
|
|
@ -490,7 +497,8 @@ export class AgentToolExecutor {
|
|||
* 5. Auto-zoom to show new design
|
||||
*/
|
||||
private async handleInsertNode(args: {
|
||||
parent: string | null;
|
||||
parent?: string | null;
|
||||
after?: string;
|
||||
data: Record<string, unknown>;
|
||||
pageId?: string;
|
||||
}): Promise<ToolResult> {
|
||||
|
|
@ -536,32 +544,58 @@ export class AgentToolExecutor {
|
|||
};
|
||||
const totalNodes = countNodes(node);
|
||||
|
||||
// Use insertStreamingNode — the SAME function the CLI streaming pipeline uses.
|
||||
// MUST call resetGenerationRemapping() first to initialize generation state
|
||||
// (preExistingNodeIds, generationRootFrameId, generationRemappedIds).
|
||||
// Without this, insertStreamingNode's ID dedup and parent resolution break.
|
||||
const { insertStreamingNode, resetGenerationRemapping, setGenerationCanvasWidth } =
|
||||
await import('@/services/ai/design-canvas-ops');
|
||||
resetGenerationRemapping();
|
||||
// Set canvas width for role resolution (mobile: 375, desktop: 1200)
|
||||
const isMobile = (node as any).width && (node as any).width <= 500;
|
||||
setGenerationCanvasWidth(isMobile ? 375 : 1200);
|
||||
const insertRecursive = (n: PenNode, parentId: string | null) => {
|
||||
const children = 'children' in n && Array.isArray(n.children) ? [...n.children] : [];
|
||||
const nodeForInsert = { ...n } as PenNode;
|
||||
if (children.length > 0) {
|
||||
(nodeForInsert as any).children = [];
|
||||
const { useDocumentStore } = await import('@/stores/document-store');
|
||||
const { findParentInTree, getActivePageChildren } =
|
||||
await import('@/stores/document-tree-utils');
|
||||
const { useCanvasStore } = await import('@/stores/canvas-store');
|
||||
const docStore = useDocumentStore.getState();
|
||||
|
||||
// Resolve "after" → parent + index
|
||||
let parentId: string | null = args.parent ?? null;
|
||||
let insertIndex: number | undefined;
|
||||
|
||||
if (args.after) {
|
||||
const doc = docStore.document;
|
||||
const activePageId = useCanvasStore.getState().activePageId;
|
||||
const children = getActivePageChildren(doc, activePageId);
|
||||
const parent = findParentInTree(children, args.after);
|
||||
if (parent) {
|
||||
parentId = parent.id;
|
||||
const siblings =
|
||||
'children' in parent && Array.isArray(parent.children) ? parent.children : [];
|
||||
const siblingIdx = siblings.findIndex((n) => n.id === args.after);
|
||||
if (siblingIdx >= 0) insertIndex = siblingIdx + 1;
|
||||
}
|
||||
insertStreamingNode(nodeForInsert, parentId);
|
||||
// Use nodeForInsert.id (not n.id) — ensureUniqueNodeIds inside
|
||||
// insertStreamingNode may have renamed it, and replaceEmptyFrame
|
||||
// maps from the renamed ID to root-frame via generationRemappedIds.
|
||||
const actualId = nodeForInsert.id;
|
||||
for (const child of children) {
|
||||
insertRecursive(child, actualId);
|
||||
}
|
||||
|
||||
// When inserting into an existing parent, use addNode directly — simple and reliable.
|
||||
// Only fall back to insertStreamingNode for root-level generation (parent=null)
|
||||
// where we need the full pipeline (replace empty frame, role resolution, etc.).
|
||||
if (parentId && docStore.getNodeById(parentId)) {
|
||||
const parentNode = docStore.getNodeById(parentId)!;
|
||||
// Strip absolute x/y if parent has auto-layout — let the layout engine position
|
||||
if ('layout' in parentNode && parentNode.layout && parentNode.layout !== 'none') {
|
||||
if ('x' in node) delete (node as { x?: number }).x;
|
||||
if ('y' in node) delete (node as { y?: number }).y;
|
||||
}
|
||||
};
|
||||
insertRecursive(node, args.parent);
|
||||
docStore.addNode(parentId, node, insertIndex);
|
||||
} else {
|
||||
// Root-level insert or unknown parent — use streaming pipeline
|
||||
const { insertStreamingNode, resetGenerationRemapping, setGenerationCanvasWidth } =
|
||||
await import('@/services/ai/design-canvas-ops');
|
||||
resetGenerationRemapping();
|
||||
const isMobile = (node as any).width && (node as any).width <= 500;
|
||||
setGenerationCanvasWidth(isMobile ? 375 : 1200);
|
||||
const insertRecursive = (n: PenNode, pid: string | null) => {
|
||||
const ch = 'children' in n && Array.isArray(n.children) ? [...n.children] : [];
|
||||
const nodeForInsert = { ...n } as PenNode;
|
||||
if (ch.length > 0) (nodeForInsert as any).children = [];
|
||||
insertStreamingNode(nodeForInsert, pid);
|
||||
const actualId = nodeForInsert.id;
|
||||
for (const child of ch) insertRecursive(child, actualId);
|
||||
};
|
||||
insertRecursive(node, parentId);
|
||||
}
|
||||
this.markContentStarted();
|
||||
|
||||
// Auto-zoom to show the new design
|
||||
|
|
@ -596,6 +630,23 @@ export class AgentToolExecutor {
|
|||
return { success: true };
|
||||
}
|
||||
|
||||
private async handleMoveNode(args: {
|
||||
id: string;
|
||||
parent: string;
|
||||
index?: number;
|
||||
}): Promise<ToolResult> {
|
||||
const { useDocumentStore } = await import('@/stores/document-store');
|
||||
const docStore = useDocumentStore.getState();
|
||||
if (!docStore.getNodeById(args.id)) {
|
||||
return { success: false, error: `Node not found: ${args.id}` };
|
||||
}
|
||||
if (!docStore.getNodeById(args.parent)) {
|
||||
return { success: false, error: `Parent not found: ${args.parent}` };
|
||||
}
|
||||
docStore.moveNode(args.id, args.parent, args.index ?? -1);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async handleDeleteNode(args: { id: string }): Promise<ToolResult> {
|
||||
const { useDocumentStore } = await import('@/stores/document-store');
|
||||
const docStore = useDocumentStore.getState();
|
||||
|
|
|
|||
|
|
@ -47,11 +47,162 @@ const TOOL_AUTH_MAP: Record<string, AuthLevel> = {
|
|||
remove_page: 'delete',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intent detection — determines which tools and prompts to load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AgentIntent = 'design' | 'crud';
|
||||
|
||||
// CJK characters aren't `\w`, so `\b` boundaries silently fail for them.
|
||||
// Keep the English list boundary-anchored (avoids `app` matching `approach`,
|
||||
// `add` matching `address`, etc.) and run a separate boundary-free pass for
|
||||
// CJK keywords.
|
||||
const DESIGN_KEYWORDS_EN =
|
||||
/\b(design|create|make|build|generate|add|insert|landing|page|screen|app|dashboard|card|hero|navbar|form|layout)\b/i;
|
||||
const DESIGN_KEYWORDS_CJK =
|
||||
/(设计|创建|生成|画|做一个|新建|增加|添加|加一个|插入|页面|界面|登录|首页|仪表盘|卡片|表单)/;
|
||||
|
||||
/** Detect whether the user's message is a design intent or a CRUD operation. */
|
||||
export function detectAgentIntent(message: string): AgentIntent {
|
||||
return DESIGN_KEYWORDS_EN.test(message) || DESIGN_KEYWORDS_CJK.test(message) ? 'design' : 'crud';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool definitions by intent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** CRUD-only tools — lightweight set for read/update/delete/move/insert operations. */
|
||||
export function getCrudToolDefs(): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: 'batch_get',
|
||||
description:
|
||||
'Search and read nodes from the document. ALWAYS call this first before update_node or delete_node to find the correct node IDs. ' +
|
||||
'With no arguments, returns top-level children (current page structure). Search by type/name patterns or read specific IDs.',
|
||||
level: TOOL_AUTH_MAP.batch_get,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Node IDs to retrieve' },
|
||||
patterns: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Search patterns to match',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'snapshot_layout',
|
||||
description:
|
||||
'Get a compact layout snapshot of the current page showing node positions and sizes',
|
||||
level: TOOL_AUTH_MAP.snapshot_layout,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'insert_node',
|
||||
description:
|
||||
'Insert a new node into the document tree. Always call snapshot_layout or batch_get first. ' +
|
||||
'Use "after" to insert next to a sibling (auto-finds parent and position), or "parent" for explicit placement.',
|
||||
level: TOOL_AUTH_MAP.insert_node,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
after: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Insert after this sibling node ID (preferred). Automatically uses the same parent and places the new node right after it.',
|
||||
},
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'Explicit parent node ID. Use "after" instead when adding next to existing elements.',
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'PenNode data (type, name, width, height, fills, children, etc.)',
|
||||
},
|
||||
},
|
||||
required: ['data'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_node',
|
||||
description: 'Update properties of an existing node by ID',
|
||||
level: TOOL_AUTH_MAP.update_node,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Node ID to update' },
|
||||
data: { type: 'object', description: 'Properties to update' },
|
||||
},
|
||||
required: ['id', 'data'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'move_node',
|
||||
description:
|
||||
'Move a node to a different parent container. Use when you need to reparent a node (e.g. move an element into a frame). ' +
|
||||
"The node will be placed at the end of the parent's children list by default.",
|
||||
level: TOOL_AUTH_MAP.move_node,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Node ID to move' },
|
||||
parent: { type: 'string', description: 'New parent node ID' },
|
||||
index: { type: 'number', description: 'Position index within parent (optional)' },
|
||||
},
|
||||
required: ['id', 'parent'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_node',
|
||||
description:
|
||||
'Delete a node (and all its children) from the document. ' +
|
||||
'Use when the user asks to remove, delete, or clear elements. ' +
|
||||
'Always call batch_get first to find the correct node ID before deleting.',
|
||||
level: TOOL_AUTH_MAP.delete_node,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Node ID to delete' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_selection',
|
||||
description: 'Get the currently selected nodes on the canvas with their full data',
|
||||
level: TOOL_AUTH_MAP.get_selection,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Design tools — minimal set forcing the model through `generate_design`.
|
||||
*
|
||||
* Weak models (e.g. MiniMax-M2.7) prefer per-node `insert_node` calls when
|
||||
* given the choice, producing scattered output instead of a coherent design.
|
||||
* Read tools (`batch_get`, `snapshot_layout`) stay so the model can inspect
|
||||
* existing context before generating. CRUD tools live in `getCrudToolDefs()`
|
||||
* and are reached via `detectAgentIntent('crud')` for surgical edits.
|
||||
*/
|
||||
export function getDesignToolDefs(): ToolDef[] {
|
||||
return [
|
||||
{
|
||||
name: 'batch_get',
|
||||
description: 'Get nodes by IDs or search patterns from the document tree',
|
||||
description:
|
||||
'Search and read nodes from the document. ALWAYS call this first before update_node or delete_node to find the correct node IDs. ' +
|
||||
'With no arguments, returns top-level children (current page structure). Search by type/name patterns or read specific IDs.',
|
||||
level: TOOL_AUTH_MAP.batch_get,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
|
|
@ -94,31 +245,6 @@ export function getDesignToolDefs(): ToolDef[] {
|
|||
required: ['prompt'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_node',
|
||||
description: 'Update properties of an existing node by ID',
|
||||
level: TOOL_AUTH_MAP.update_node,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Node ID to update' },
|
||||
data: { type: 'object', description: 'Properties to update' },
|
||||
},
|
||||
required: ['id', 'data'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_node',
|
||||
description: 'Delete a node from the document by ID',
|
||||
level: TOOL_AUTH_MAP.delete_node,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Node ID to delete' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -177,14 +303,22 @@ export function getAllToolDefs(): ToolDef[] {
|
|||
},
|
||||
{
|
||||
name: 'insert_node',
|
||||
description: 'Insert a new node into the document tree with full support for nested children',
|
||||
description:
|
||||
'Insert a new node into the document tree. Always call snapshot_layout or batch_get first. ' +
|
||||
'Use "after" to insert next to a sibling (auto-finds parent and position), or "parent" for explicit placement.',
|
||||
level: TOOL_AUTH_MAP.insert_node,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
after: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Insert after this sibling node ID (preferred). Automatically uses the same parent and places the new node right after it.',
|
||||
},
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Parent node ID, or null for root-level insertion',
|
||||
description:
|
||||
'Explicit parent node ID. Use "after" instead when adding next to existing elements.',
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
|
|
@ -195,7 +329,7 @@ export function getAllToolDefs(): ToolDef[] {
|
|||
description: 'Target page ID (optional, defaults to active page)',
|
||||
},
|
||||
},
|
||||
required: ['parent', 'data'],
|
||||
required: ['data'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,36 @@ function loadSkill(name: string): string {
|
|||
return getSkillByName(name)?.content ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip fields that don't influence code generation. Keeps request bodies small
|
||||
* enough that proxies don't reject them with 403/413, and reduces input tokens.
|
||||
*/
|
||||
function stripNoise(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(stripNoise);
|
||||
if (!input || typeof input !== 'object') return input;
|
||||
const obj = input as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
// Drop fields the generator doesn't need:
|
||||
// - id (model picks its own names)
|
||||
// - parentId / pageId (tree structure is implicit in nesting)
|
||||
// - x/y on auto-layout children (layout engine positions them)
|
||||
// - effects we typically can't translate (skip noisy nested props)
|
||||
// - rotation/opacity/visible when default
|
||||
if (k === 'id' || k === 'parentId' || k === 'pageId' || k === '_meta') continue;
|
||||
if (k === 'rotation' && v === 0) continue;
|
||||
if (k === 'opacity' && v === 1) continue;
|
||||
if (k === 'visible' && v === true) continue;
|
||||
out[k] = stripNoise(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Compact JSON for AI prompts (no indentation, drops noise fields). */
|
||||
function compactNodes(nodes: PenNode[]): string {
|
||||
return JSON.stringify(stripNoise(nodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt for Step 1: planning.
|
||||
*/
|
||||
|
|
@ -67,7 +97,7 @@ export function buildChunkPrompt(
|
|||
`Generate a ${framework} component named "${suggestedComponentName}".`,
|
||||
'',
|
||||
'Nodes (JSON):',
|
||||
JSON.stringify(nodes, null, 2),
|
||||
compactNodes(nodes),
|
||||
depSection,
|
||||
'',
|
||||
'Output the code followed by ---CONTRACT--- and the JSON contract.',
|
||||
|
|
|
|||
|
|
@ -185,8 +185,12 @@ export function createMobileStatusBar(variant: StatusBarVariant = 'dark'): PenNo
|
|||
* Determines the best status bar variant based on a background color.
|
||||
* Returns 'light' (white icons) for dark backgrounds, 'dark' (black icons) for light ones.
|
||||
*/
|
||||
export function inferStatusBarVariant(bgColor?: string): StatusBarVariant {
|
||||
if (!bgColor) return 'dark';
|
||||
export function inferStatusBarVariant(bgColor?: string | unknown): StatusBarVariant {
|
||||
// Defensive: callers occasionally pass non-string values (e.g. a fill
|
||||
// object or a `$variable` ref-shaped object) when the upstream PenNode
|
||||
// hasn't been variable-resolved yet. Bail to the safe default instead
|
||||
// of throwing `bgColor.replace is not a function`.
|
||||
if (typeof bgColor !== 'string' || !bgColor) return 'dark';
|
||||
const hex = bgColor.replace(/^#/, '').slice(0, 6);
|
||||
if (hex.length !== 6) return 'dark';
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
MCPCliIntegration,
|
||||
MCPTransportMode,
|
||||
GroupedModel,
|
||||
AcpAgentConfig,
|
||||
} from '@/types/agent-settings';
|
||||
import type { ImageGenConfig, ImageGenProfile } from '@/types/image-service';
|
||||
import { DEFAULT_IMAGE_GEN_CONFIG } from '@/types/image-service';
|
||||
|
|
@ -56,11 +57,16 @@ interface PersistedState {
|
|||
activeImageGenProfileId: string | null;
|
||||
openverseOAuth: { clientId: string; clientSecret: string } | null;
|
||||
builtinProviders: BuiltinProviderConfig[];
|
||||
acpAgents: AcpAgentConfig[];
|
||||
teamEnabled: boolean;
|
||||
teamDesignModel: string | null;
|
||||
}
|
||||
|
||||
interface AgentSettingsState extends PersistedState {
|
||||
acpConnectionStatus: Record<
|
||||
string,
|
||||
{ isConnected: boolean; agentInfo?: { name: string; title?: string; version?: string } }
|
||||
>;
|
||||
dialogOpen: boolean;
|
||||
isHydrated: boolean;
|
||||
mcpServerRunning: boolean;
|
||||
|
|
@ -88,6 +94,16 @@ interface AgentSettingsState extends PersistedState {
|
|||
addBuiltinProvider: (config: Omit<BuiltinProviderConfig, 'id'>) => string;
|
||||
updateBuiltinProvider: (id: string, updates: Partial<BuiltinProviderConfig>) => void;
|
||||
removeBuiltinProvider: (id: string) => void;
|
||||
addAcpAgent: (config: Omit<AcpAgentConfig, 'id'>) => string;
|
||||
updateAcpAgent: (id: string, updates: Partial<AcpAgentConfig>) => void;
|
||||
removeAcpAgent: (id: string) => void;
|
||||
setAcpConnectionStatus: (
|
||||
id: string,
|
||||
status: {
|
||||
isConnected: boolean;
|
||||
agentInfo?: { name: string; title?: string; version?: string };
|
||||
},
|
||||
) => void;
|
||||
setTeamEnabled: (enabled: boolean) => void;
|
||||
setTeamDesignModel: (model: string | null) => void;
|
||||
persist: () => void;
|
||||
|
|
@ -151,8 +167,10 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
|
|||
activeImageGenProfileId: null,
|
||||
openverseOAuth: null,
|
||||
builtinProviders: [],
|
||||
acpAgents: [],
|
||||
teamEnabled: false,
|
||||
teamDesignModel: null,
|
||||
acpConnectionStatus: {},
|
||||
dialogOpen: false,
|
||||
isHydrated: false,
|
||||
mcpServerRunning: false,
|
||||
|
|
@ -264,6 +282,33 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
|
|||
builtinProviders: s.builtinProviders.filter((p) => p.id !== id),
|
||||
})),
|
||||
|
||||
addAcpAgent: (config) => {
|
||||
const id = `acp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
set((s) => ({ acpAgents: [...s.acpAgents, { ...config, id }] }));
|
||||
get().persist();
|
||||
return id;
|
||||
},
|
||||
updateAcpAgent: (id, updates) => {
|
||||
set((s) => ({
|
||||
acpAgents: s.acpAgents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
||||
}));
|
||||
get().persist();
|
||||
},
|
||||
removeAcpAgent: (id) => {
|
||||
set((s) => ({
|
||||
acpAgents: s.acpAgents.filter((a) => a.id !== id),
|
||||
acpConnectionStatus: Object.fromEntries(
|
||||
Object.entries(s.acpConnectionStatus).filter(([k]) => k !== id),
|
||||
),
|
||||
}));
|
||||
get().persist();
|
||||
},
|
||||
setAcpConnectionStatus: (id, status) => {
|
||||
set((s) => ({
|
||||
acpConnectionStatus: { ...s.acpConnectionStatus, [id]: status },
|
||||
}));
|
||||
},
|
||||
|
||||
setTeamEnabled: (teamEnabled) => set({ teamEnabled }),
|
||||
setTeamDesignModel: (teamDesignModel) => set({ teamDesignModel }),
|
||||
|
||||
|
|
@ -279,6 +324,7 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
|
|||
activeImageGenProfileId,
|
||||
openverseOAuth,
|
||||
builtinProviders,
|
||||
acpAgents,
|
||||
teamEnabled,
|
||||
teamDesignModel,
|
||||
} = get();
|
||||
|
|
@ -294,6 +340,7 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
|
|||
activeImageGenProfileId,
|
||||
openverseOAuth,
|
||||
builtinProviders,
|
||||
acpAgents,
|
||||
teamEnabled,
|
||||
teamDesignModel,
|
||||
}),
|
||||
|
|
@ -357,6 +404,10 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
|
|||
) as BuiltinProviderConfig[],
|
||||
});
|
||||
}
|
||||
const stored = data as Record<string, unknown>;
|
||||
set({
|
||||
acpAgents: Array.isArray(stored.acpAgents) ? (stored.acpAgents as AcpAgentConfig[]) : [],
|
||||
});
|
||||
if ((data as Record<string, unknown>).teamEnabled !== undefined) {
|
||||
set({ teamEnabled: (data as Record<string, unknown>).teamEnabled as boolean });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,13 +34,24 @@ export interface GroupedModel {
|
|||
value: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
provider: AIProviderType;
|
||||
provider: AIProviderType | string;
|
||||
/** When set, this model came from a built-in provider (API key) rather than a CLI tool */
|
||||
builtinProviderId?: string;
|
||||
}
|
||||
|
||||
export interface ModelGroup {
|
||||
provider: AIProviderType;
|
||||
provider: AIProviderType | string;
|
||||
providerName: string;
|
||||
models: GroupedModel[];
|
||||
}
|
||||
|
||||
export interface AcpAgentConfig {
|
||||
id: string;
|
||||
displayName: string;
|
||||
connectionType: 'local' | 'remote';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
|
|
|||
2
apps/web/src/types/electron.d.ts
vendored
2
apps/web/src/types/electron.d.ts
vendored
|
|
@ -43,6 +43,8 @@ declare global {
|
|||
readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>;
|
||||
getPendingFile: () => Promise<string | null>;
|
||||
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void;
|
||||
/** Resolve the absolute filesystem path of a File from drag-and-drop. */
|
||||
getPathForFile: (file: File) => string | null;
|
||||
confirmClose: () => void;
|
||||
confirmUnsavedChanges: (payload: {
|
||||
message: string;
|
||||
|
|
|
|||
46
bun.lock
46
bun.lock
|
|
@ -5,6 +5,7 @@
|
|||
"": {
|
||||
"name": "openpencil",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.18.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
|
||||
"@anthropic-ai/sdk": "^0.77.0",
|
||||
"@github/copilot-sdk": "^0.1.32",
|
||||
|
|
@ -78,7 +79,7 @@
|
|||
},
|
||||
"apps/cli": {
|
||||
"name": "@zseven-w/openpencil",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"bin": {
|
||||
"op": "dist/openpencil-cli.cjs",
|
||||
},
|
||||
|
|
@ -89,13 +90,14 @@
|
|||
},
|
||||
"apps/desktop": {
|
||||
"name": "@zseven-w/desktop",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@zseven-w/web",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"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:*",
|
||||
|
|
@ -109,11 +111,23 @@
|
|||
},
|
||||
"packages/agent-native/napi": {
|
||||
"name": "@zseven-w/agent-native",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
},
|
||||
"packages/pen-acp": {
|
||||
"name": "@zseven-w/pen-acp",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.18.2",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.7.2",
|
||||
},
|
||||
},
|
||||
"packages/pen-ai-skills": {
|
||||
"name": "@zseven-w/pen-ai-skills",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-types": "workspace:*",
|
||||
"gray-matter": "^4.0.3",
|
||||
|
|
@ -122,7 +136,7 @@
|
|||
},
|
||||
"packages/pen-core": {
|
||||
"name": "@zseven-w/pen-core",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-types": "workspace:*",
|
||||
"nanoid": "^5.1.6",
|
||||
|
|
@ -134,7 +148,7 @@
|
|||
},
|
||||
"packages/pen-engine": {
|
||||
"name": "@zseven-w/pen-engine",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-core": "workspace:*",
|
||||
"@zseven-w/pen-figma": "workspace:*",
|
||||
|
|
@ -155,7 +169,7 @@
|
|||
},
|
||||
"packages/pen-figma": {
|
||||
"name": "@zseven-w/pen-figma",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-types": "workspace:*",
|
||||
"fzstd": "^0.1.1",
|
||||
|
|
@ -169,7 +183,7 @@
|
|||
},
|
||||
"packages/pen-mcp": {
|
||||
"name": "@zseven-w/pen-mcp",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@iconify-json/feather": "^1.2.1",
|
||||
"@iconify-json/lucide": "^1.2.93",
|
||||
|
|
@ -186,7 +200,7 @@
|
|||
},
|
||||
"packages/pen-react": {
|
||||
"name": "@zseven-w/pen-react",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-core": "workspace:*",
|
||||
"@zseven-w/pen-engine": "workspace:*",
|
||||
|
|
@ -215,7 +229,7 @@
|
|||
},
|
||||
"packages/pen-renderer": {
|
||||
"name": "@zseven-w/pen-renderer",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-core": "workspace:*",
|
||||
"@zseven-w/pen-types": "workspace:*",
|
||||
|
|
@ -232,7 +246,7 @@
|
|||
},
|
||||
"packages/pen-sdk": {
|
||||
"name": "@zseven-w/pen-sdk",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-core": "workspace:*",
|
||||
"@zseven-w/pen-engine": "workspace:*",
|
||||
|
|
@ -247,7 +261,7 @@
|
|||
},
|
||||
"packages/pen-types": {
|
||||
"name": "@zseven-w/pen-types",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2",
|
||||
},
|
||||
|
|
@ -258,6 +272,8 @@
|
|||
|
||||
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.18.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-l/o9NKvUc00GPa6RFJ4AccQq2O/PAf83xQ75mThHuL3H571iN4+PEdwnTBez67sS8Nv2aSA373xCZ5CbTXEwzA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.97", "", { "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-754teaU0nfrn9BC0YWzPjSbJj253GfPUtuUnkrde7LGsaKtFSjEEuQJq5skJvpozqcn+B8frrtWVPkvFdnupTw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.77.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A=="],
|
||||
|
|
@ -892,6 +908,8 @@
|
|||
|
||||
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||
|
|
@ -920,6 +938,8 @@
|
|||
|
||||
"@zseven-w/openpencil": ["@zseven-w/openpencil@workspace:apps/cli"],
|
||||
|
||||
"@zseven-w/pen-acp": ["@zseven-w/pen-acp@workspace:packages/pen-acp"],
|
||||
|
||||
"@zseven-w/pen-ai-skills": ["@zseven-w/pen-ai-skills@workspace:packages/pen-ai-skills"],
|
||||
|
||||
"@zseven-w/pen-core": ["@zseven-w/pen-core@workspace:packages/pen-core"],
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "openpencil",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
|
||||
"author": {
|
||||
|
|
@ -26,13 +26,16 @@
|
|||
"mcp:dev": "bun run packages/pen-mcp/src/server.ts",
|
||||
"electron:dev": "bun run apps/desktop/dev.ts",
|
||||
"electron:compile": "esbuild apps/desktop/main.ts apps/desktop/preload.ts --bundle --platform=node --target=node20 --outdir=out/desktop --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-types=./packages/pen-types/src",
|
||||
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml",
|
||||
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && bun run agent-native:bundle && npx electron-builder --config apps/desktop/electron-builder.yml",
|
||||
"electron:build:mac-arm64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --arm64 && if [ -f out/release/latest-mac.yml ]; then mv out/release/latest-mac.yml out/release/latest-mac-arm64.yml; fi",
|
||||
"electron:build:mac-x64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --x64",
|
||||
"electron:build:mac-universal": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --arm64 --x64",
|
||||
"electron:build:mac-both": "bun run electron:build:mac-arm64 && bun run electron:build:mac-x64",
|
||||
"cli:compile": "esbuild apps/cli/src/index.ts --bundle --platform=node --target=node20 --outfile=apps/cli/dist/openpencil-cli.cjs --format=cjs --sourcemap --alias:@zseven-w/pen-types=./packages/pen-types/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-figma=./packages/pen-figma/src --alias:@zseven-w/pen-renderer=./packages/pen-renderer/src --alias:@zseven-w/pen-sdk=./packages/pen-sdk/src --alias:@zseven-w/pen-ai-skills=./packages/pen-ai-skills/src --alias:@zseven-w/pen-mcp=./packages/pen-mcp/src --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --define:import.meta.env={} --external:canvas --external:paper",
|
||||
"cli:bundle-skill": "bun scripts/bundle-skill.ts",
|
||||
"cli:compile": "bun run cli:bundle-skill && esbuild apps/cli/src/index.ts --bundle --platform=node --target=node20 --outfile=apps/cli/dist/openpencil-cli.cjs --format=cjs --sourcemap --alias:@zseven-w/pen-types=./packages/pen-types/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-figma=./packages/pen-figma/src --alias:@zseven-w/pen-renderer=./packages/pen-renderer/src --alias:@zseven-w/pen-sdk=./packages/pen-sdk/src --alias:@zseven-w/pen-ai-skills=./packages/pen-ai-skills/src --alias:@zseven-w/pen-mcp=./packages/pen-mcp/src --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --alias:@zseven-w/pen-acp=./packages/pen-acp/src --define:import.meta.env={} --external:canvas --external:paper",
|
||||
"cli:dev": "bun run apps/cli/src/index.ts",
|
||||
"acp:dev": "bun run packages/pen-acp/src/server.ts",
|
||||
"acp:compile": "esbuild packages/pen-acp/src/server.ts --bundle --platform=node --target=node20 --outfile=out/acp-server.cjs --format=cjs --sourcemap --alias:@zseven-w/pen-types=./packages/pen-types/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-figma=./packages/pen-figma/src --alias:@zseven-w/pen-renderer=./packages/pen-renderer/src --alias:@zseven-w/pen-sdk=./packages/pen-sdk/src --alias:@zseven-w/pen-ai-skills=./packages/pen-ai-skills/src --alias:@zseven-w/pen-mcp=./packages/pen-mcp/src --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --define:import.meta.env={} --external:canvas --external:paper",
|
||||
"publish:beta": "bash scripts/publish-beta.sh",
|
||||
"unpublish": "bash scripts/unpublish.sh",
|
||||
"bump": "sh -c 'V=$0; [ -z \"$V\" ] && echo \"Usage: bun run bump <version>\" && exit 1; for f in package.json apps/*/package.json packages/*/package.json; do [ -f \"$f\" ] && jq --arg v \"$V\" \".version=\\$v\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\" && echo \"$f → $V\"; done'",
|
||||
|
|
@ -42,9 +45,11 @@
|
|||
"format:check": "oxfmt --check .",
|
||||
"generate": "bun -e \"import { compileSkills } from './packages/pen-ai-skills/vite-plugin-skills'; compileSkills('./packages/pen-ai-skills')\"",
|
||||
"agent:build": "cd packages/agent-native && zig build napi -Doptimize=ReleaseFast",
|
||||
"agent-native:bundle": "node -e \"const fs=require('fs'),p=require('path');const src=p.resolve('packages/agent-native/zig-out/napi/agent_napi.node');const dst=p.resolve('packages/agent-native/napi/agent_napi.node');if(!fs.existsSync(src)){console.error('Native binary missing at '+src+'. Run bun run agent:build first.');process.exit(1)}fs.copyFileSync(src,dst);console.log('[agent-native:bundle] copied to '+dst)\"",
|
||||
"postinstall": "(bun scripts/patch-srvx-bun.ts && bun run generate && node scripts/ensure-agent-native.cjs) || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.18.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
|
||||
"@anthropic-ai/sdk": "^0.77.0",
|
||||
"@github/copilot-sdk": "^0.1.32",
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 7a309968515df9bafb5d12eb4716299b402b556f
|
||||
Subproject commit e07d743a16ccab79956ff9f5bce5b0729ac9e701
|
||||
23
packages/pen-acp/package.json
Normal file
23
packages/pen-acp/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-acp",
|
||||
"version": "0.7.1",
|
||||
"description": "ACP (Agent Client Protocol) client for OpenPencil — connect to external ACP agents",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.18.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
144
packages/pen-acp/src/client.ts
Normal file
144
packages/pen-acp/src/client.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
|
||||
import type { AcpAgentConfig, AcpConnectionState } from './types';
|
||||
|
||||
/** Establish an ACP connection to a local process or remote endpoint. */
|
||||
export async function connectAcpAgent(config: AcpAgentConfig): Promise<AcpConnectionState> {
|
||||
if (config.connectionType === 'local') {
|
||||
return connectLocal(config);
|
||||
}
|
||||
return connectRemote(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ClientSideConnection from a bidirectional ndJSON stream.
|
||||
* The Client.sessionUpdate callback dispatches events to
|
||||
* state.sessionUpdateEmitter so the SSE handler can consume them.
|
||||
*/
|
||||
function createConnection(
|
||||
stream: ReturnType<typeof ndJsonStream>,
|
||||
state: AcpConnectionState,
|
||||
): ClientSideConnection {
|
||||
return new ClientSideConnection(
|
||||
(_agent) => ({
|
||||
sessionUpdate: async (params) => {
|
||||
state.sessionUpdateEmitter?.dispatchEvent(new CustomEvent('update', { detail: params }));
|
||||
},
|
||||
// Auto-approve all tool calls. The user already established trust by
|
||||
// connecting this ACP agent in settings. Claude Agent ACP requests
|
||||
// permission before each MCP tool call — if we don't approve, tools
|
||||
// fail with "Tool use aborted".
|
||||
// Future: route through AgentToolExecutor's TOOL_AUTH_MAP if per-call
|
||||
// approval is needed for destructive operations.
|
||||
requestPermission: async (params) => {
|
||||
// Prefer the first allow option if present; fall back to generic allow.
|
||||
const allowOption = params.options?.find(
|
||||
(o) =>
|
||||
o.kind === 'allow_once' || o.kind === 'allow_always' || o.optionId.startsWith('allow'),
|
||||
);
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'selected' as const,
|
||||
optionId: allowOption?.optionId ?? params.options?.[0]?.optionId ?? 'allow',
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
stream,
|
||||
);
|
||||
}
|
||||
|
||||
async function connectLocal(config: AcpAgentConfig): Promise<AcpConnectionState> {
|
||||
if (!config.command) throw new Error('Local ACP agent requires a command');
|
||||
|
||||
const proc = spawn(config.command, config.args ?? [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...config.env },
|
||||
});
|
||||
|
||||
// node:stream toWeb returns ReadableStream<any>; ndJsonStream expects
|
||||
// ReadableStream<Uint8Array>. The runtime data is bytes, so the cast is
|
||||
// safe — only TypeScript's variance is too strict here.
|
||||
const input = Writable.toWeb(proc.stdin!) as WritableStream<Uint8Array>;
|
||||
const output = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
|
||||
const stream = ndJsonStream(input, output);
|
||||
|
||||
const state: AcpConnectionState = {
|
||||
connection: null!,
|
||||
agentInfo: { name: 'unknown' },
|
||||
process: proc,
|
||||
sessionUpdateEmitter: null,
|
||||
};
|
||||
state.connection = createConnection(stream, state);
|
||||
|
||||
const initResult = await state.connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {},
|
||||
clientInfo: { name: 'openpencil', version: '0.7.1' },
|
||||
});
|
||||
|
||||
state.agentInfo = {
|
||||
name: initResult.agentInfo?.name ?? config.displayName,
|
||||
title: initResult.agentInfo?.title ?? undefined,
|
||||
version: initResult.agentInfo?.version ?? undefined,
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function connectRemote(config: AcpAgentConfig): Promise<AcpConnectionState> {
|
||||
if (!config.url) throw new Error('Remote ACP agent requires a URL');
|
||||
|
||||
const { WebSocket: WS } = await import('ws');
|
||||
const ws = new WS(config.url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener('open', () => resolve());
|
||||
ws.addEventListener('error', (e) => reject(new Error(`WebSocket error: ${e}`)));
|
||||
});
|
||||
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
ws.addEventListener('message', (e) => {
|
||||
const data = typeof e.data === 'string' ? e.data : String(e.data);
|
||||
controller.enqueue(new TextEncoder().encode(data + '\n'));
|
||||
});
|
||||
ws.addEventListener('close', () => controller.close());
|
||||
},
|
||||
});
|
||||
const writable = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
ws.send(new TextDecoder().decode(chunk));
|
||||
},
|
||||
});
|
||||
|
||||
const stream = ndJsonStream(writable, readable);
|
||||
|
||||
const state: AcpConnectionState = {
|
||||
connection: null!,
|
||||
agentInfo: { name: 'unknown' },
|
||||
sessionUpdateEmitter: null,
|
||||
};
|
||||
state.connection = createConnection(stream, state);
|
||||
|
||||
const initResult = await state.connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {},
|
||||
clientInfo: { name: 'openpencil', version: '0.7.1' },
|
||||
});
|
||||
|
||||
state.agentInfo = {
|
||||
name: initResult.agentInfo?.name ?? config.displayName,
|
||||
title: initResult.agentInfo?.title ?? undefined,
|
||||
version: initResult.agentInfo?.version ?? undefined,
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Disconnect an ACP connection and kill the process if local. */
|
||||
export function disconnectAcpAgent(state: AcpConnectionState): void {
|
||||
if (state.process) {
|
||||
state.process.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
70
packages/pen-acp/src/event-adapter.ts
Normal file
70
packages/pen-acp/src/event-adapter.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||
|
||||
/** Convert an ACP session/update notification to an OpenPencil SSE event string. */
|
||||
export function acpUpdateToSSE(notification: SessionNotification): string | null {
|
||||
const update = notification.update;
|
||||
if (!update) return null;
|
||||
|
||||
switch (update.sessionUpdate) {
|
||||
case 'agent_message_chunk': {
|
||||
const content = update.content;
|
||||
if (content && 'text' in content && content.type === 'text') {
|
||||
return formatSSE('text', { type: 'text', content: content.text });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool_call': {
|
||||
// ACP tool calls are display-only — the agent executes them via MCP.
|
||||
// level: 'orchestrate' makes AgentToolExecutor skip execution.
|
||||
return formatSSE('tool_call', {
|
||||
type: 'tool_call',
|
||||
id: update.toolCallId,
|
||||
name: update.title ?? 'unknown',
|
||||
args: update.rawInput ?? {},
|
||||
level: 'orchestrate',
|
||||
});
|
||||
}
|
||||
|
||||
case 'tool_call_update': {
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
// On failure, extract error details from content blocks (ACP places
|
||||
// error text in content[].content when tool execution fails).
|
||||
let errorMsg: string | undefined;
|
||||
if (update.status === 'failed') {
|
||||
const content = (update as { content?: Array<{ content?: unknown }> }).content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block?.content && typeof block.content === 'object' && 'text' in block.content) {
|
||||
errorMsg = (block.content as { text?: string }).text ?? errorMsg;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Log to server console for debugging
|
||||
console.error(
|
||||
`[acp] tool ${update.toolCallId} failed:`,
|
||||
errorMsg ?? JSON.stringify(update.rawOutput),
|
||||
);
|
||||
}
|
||||
return formatSSE('tool_result', {
|
||||
type: 'tool_result',
|
||||
id: update.toolCallId,
|
||||
name: '',
|
||||
result: {
|
||||
success: update.status === 'completed',
|
||||
data: update.rawOutput,
|
||||
error: errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSSE(event: string, data: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
3
packages/pen-acp/src/index.ts
Normal file
3
packages/pen-acp/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type { AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpConnectionState } from './types';
|
||||
export { connectAcpAgent, disconnectAcpAgent } from './client';
|
||||
export { acpUpdateToSSE } from './event-adapter';
|
||||
41
packages/pen-acp/src/types.ts
Normal file
41
packages/pen-acp/src/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { ClientSideConnection } from '@agentclientprotocol/sdk';
|
||||
|
||||
/** Persisted config for a user-configured ACP agent. */
|
||||
export interface AcpAgentConfig {
|
||||
id: string;
|
||||
displayName: string;
|
||||
connectionType: 'local' | 'remote';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Info returned by the ACP agent during initialize handshake. */
|
||||
export interface AcpAgentInfo {
|
||||
name: string;
|
||||
title?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/** Result of a connect attempt. */
|
||||
export interface AcpConnectResult {
|
||||
connected: boolean;
|
||||
agentInfo?: AcpAgentInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Live connection state held by the connection manager. */
|
||||
export interface AcpConnectionState {
|
||||
connection: ClientSideConnection;
|
||||
agentInfo: AcpAgentInfo;
|
||||
process?: import('node:child_process').ChildProcess;
|
||||
/**
|
||||
* Session-scoped event emitter for session/update notifications.
|
||||
* Set by the prompt handler before calling connection.prompt().
|
||||
* The Client.sessionUpdate callback pushes events here;
|
||||
* the SSE stream handler consumes them.
|
||||
*/
|
||||
sessionUpdateEmitter: EventTarget | null;
|
||||
}
|
||||
21
packages/pen-ai-skills/LICENSE
Normal file
21
packages/pen-ai-skills/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
181
packages/pen-ai-skills/README.md
Normal file
181
packages/pen-ai-skills/README.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# @zseven-w/pen-ai-skills
|
||||
|
||||
AI prompt skill engine for [OpenPencil](https://github.com/ZSeven-W/openpencil) — phase-driven prompt loading with intent matching, token budgets, and design memory.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @zseven-w/pen-ai-skills
|
||||
# or
|
||||
bun add @zseven-w/pen-ai-skills
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
When an LLM generates designs for OpenPencil, it needs context: PenNode schema, layout rules, semantic roles, icon names, style guides, and more. Loading everything at once wastes tokens. This package solves that with **phase-based skill resolution** — only the relevant prompts are loaded for each stage of the design workflow.
|
||||
|
||||
```
|
||||
User message ──► resolveSkills(phase, message, options)
|
||||
│
|
||||
├─ Phase filter (planning / generation / validation / maintenance)
|
||||
├─ Intent matching (landing page? dashboard? form? mobile app?)
|
||||
├─ Flag-based activation (hasDesignMd? hasVariables?)
|
||||
├─ Priority sorting (higher priority = selected first)
|
||||
└─ Token budget trimming (cap per phase)
|
||||
│
|
||||
▼
|
||||
AgentContext { skills[], memory, budget }
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { resolveSkills } from '@zseven-w/pen-ai-skills';
|
||||
|
||||
// Generation phase — user wants a landing page
|
||||
const ctx = resolveSkills('generation', 'design a SaaS landing page', {
|
||||
flags: { hasDesignMd: false, hasVariables: true },
|
||||
});
|
||||
|
||||
// ctx.skills contains the relevant prompts
|
||||
for (const skill of ctx.skills) {
|
||||
console.log(`${skill.meta.name} (${skill.tokenCount} tokens)`);
|
||||
// schema (800 tokens)
|
||||
// layout (600 tokens)
|
||||
// landing-page (400 tokens)
|
||||
// ...
|
||||
}
|
||||
|
||||
// Budget tracking
|
||||
console.log(`${ctx.budget.used} / ${ctx.budget.max} tokens used`);
|
||||
```
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Budget | Purpose |
|
||||
| ------------- | ------ | -------------------------------------------------------- |
|
||||
| `planning` | 4,000 | Analyze requirements, plan sections, choose style |
|
||||
| `generation` | 8,000 | Generate PenNode trees with full design knowledge |
|
||||
| `validation` | 3,000 | Check layout, spacing, accessibility, best practices |
|
||||
| `maintenance` | 5,000 | Edit existing nodes, delete, reparent, modify properties |
|
||||
|
||||
## Skill Categories
|
||||
|
||||
### Base Skills
|
||||
|
||||
Core design principles and workflow guides. Always loaded for their phase.
|
||||
|
||||
### Domain Skills
|
||||
|
||||
Activated by intent matching — keywords in the user message trigger specialized knowledge:
|
||||
|
||||
| Skill | Triggers |
|
||||
| -------------- | ----------------------------------------------- |
|
||||
| Landing page | `landing`, `marketing`, `homepage` |
|
||||
| Dashboard | `dashboard`, `admin`, `analytics` |
|
||||
| Form UI | `form`, `login`, `signup`, `input` |
|
||||
| Mobile app | `mobile`, `app`, `screen`, `ios`, `android` |
|
||||
| CJK typography | `chinese`, `japanese`, `korean`, CJK characters |
|
||||
|
||||
### Knowledge Skills
|
||||
|
||||
Reference material loaded by priority until the token budget is exhausted:
|
||||
|
||||
- **Role definitions** — semantic roles (button, input, card, navbar) and their auto-defaults
|
||||
- **Icon catalog** — Lucide/Feather icon naming conventions
|
||||
- **Design examples** — complete component patterns in DSL
|
||||
- **Copywriting** — headline length, CTA text, placeholder copy rules
|
||||
- **Code generation guides** — React, Vue, Svelte, Flutter, SwiftUI, Compose, React Native, HTML
|
||||
|
||||
## Design Memory
|
||||
|
||||
Track context across multi-turn generation sessions:
|
||||
|
||||
### Document Context
|
||||
|
||||
```typescript
|
||||
import { createDesignContext, contextToPromptString } from '@zseven-w/pen-ai-skills';
|
||||
|
||||
const ctx = createDesignContext('/path/to/doc.op');
|
||||
// Accumulates: palette, typography, spacing, aesthetic, page structure
|
||||
|
||||
const prompt = contextToPromptString(ctx);
|
||||
// "Design system: palette #2563EB, #F8FAFC; font Space Grotesk / Inter; ..."
|
||||
```
|
||||
|
||||
### Generation History
|
||||
|
||||
```typescript
|
||||
import { createHistoryEntry, getRecentEntries } from '@zseven-w/pen-ai-skills';
|
||||
|
||||
const entry = createHistoryEntry({
|
||||
documentPath: '/path/to/doc.op',
|
||||
prompt: 'design a pricing section',
|
||||
phase: 'generation',
|
||||
skillsUsed: ['schema', 'layout', 'landing-page'],
|
||||
nodeCount: 28,
|
||||
sectionTypes: ['pricing'],
|
||||
});
|
||||
|
||||
// Feed recent history back to prevent repetitive designs
|
||||
const recent = getRecentEntries(allEntries, 5);
|
||||
```
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Detect common design issues in generated output:
|
||||
|
||||
```typescript
|
||||
import { detectAllIssues } from '@zseven-w/pen-ai-skills';
|
||||
|
||||
const issues = detectAllIssues(document);
|
||||
// [{ severity: 'warning', category: 'invisible-container', nodeId: 'frame-7', message: '...' }]
|
||||
```
|
||||
|
||||
| Detector | Catches |
|
||||
| ----------------------- | -------------------------------------------------------- |
|
||||
| Invisible containers | Frames with no fill, stroke, or visual children |
|
||||
| Empty paths | Path nodes with no `d` attribute |
|
||||
| Text explicit heights | Text nodes with hardcoded pixel height (causes overflow) |
|
||||
| Sibling inconsistencies | Siblings with mixed width strategies in the same layout |
|
||||
|
||||
## Adding a Skill
|
||||
|
||||
Create a Markdown file in `skills/` with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-custom-skill
|
||||
description: Guidelines for designing checkout flows
|
||||
phase: [generation, validation]
|
||||
trigger:
|
||||
keywords: [checkout, cart, payment, purchase]
|
||||
priority: 8
|
||||
budget: 1500
|
||||
category: domain
|
||||
---
|
||||
|
||||
## Checkout Flow Design Rules
|
||||
|
||||
1. Always show order summary alongside the form
|
||||
2. Use a single-column layout for payment fields
|
||||
3. ...
|
||||
```
|
||||
|
||||
The Vite plugin auto-compiles skills into a TypeScript registry on save during development.
|
||||
|
||||
## Style Guide
|
||||
|
||||
Parse and apply external style guides:
|
||||
|
||||
```typescript
|
||||
import { parseStyleGuideFile, buildStyleMapping } from '@zseven-w/pen-ai-skills';
|
||||
|
||||
const guide = parseStyleGuideFile(markdownContent);
|
||||
const mappings = buildStyleMapping(guide);
|
||||
// [{ property: 'fill', from: '#000', to: '$text-primary' }, ...]
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
|
@ -1,6 +1,20 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-ai-skills",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-ai-skills",
|
||||
"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": "packages/pen-ai-skills"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"skills",
|
||||
|
|
|
|||
40
packages/pen-core/CLAUDE.md
Normal file
40
packages/pen-core/CLAUDE.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# pen-core
|
||||
|
||||
Pure document tree operations, layout engine, variables, normalization, boolean ops, and merge utilities.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/tree-utils.ts` — Tree CRUD: `findNodeInTree`, `findParentInTree`, `removeNodeFromTree`, `updateNodeInTree`, `flattenNodes`, `insertNodeInTree`, `isDescendantOf`, `getNodeBounds`, `findClearX`, `scaleChildrenInPlace`, `rotateChildrenInPlace`, `nodeTreeToSummary`; page helpers: `createEmptyDocument`, `getActivePage`, `getActivePageChildren`, `setActivePageChildren`, `getAllChildren`, `migrateToPages`, `ensureDocumentNodeIds`; clone utilities: `deepCloneNode`, `cloneNodeWithNewIds`, `cloneNodesWithNewIds`; constants: `DEFAULT_FRAME_ID`, `DEFAULT_PAGE_ID`
|
||||
- `src/normalize.ts` — `normalizePenDocument`: format-only normalization (fill type "color" to "solid", gradient stop position to offset, sizing strings, padding arrays). Preserves `$variable` refs
|
||||
- `src/boolean-ops.ts` — `canBooleanOp`, `executeBooleanOp` (union/subtract/intersect via Paper.js headless)
|
||||
- `src/sync-lock.ts` — `isFabricSyncLocked`, `setFabricSyncLock`: prevents circular document-store to canvas sync
|
||||
- `src/arc-path.ts` — `buildEllipseArcPath`, `isArcEllipse`: SVG arc path generation for partial ellipses
|
||||
- `src/path-anchors.ts` — `anchorsToPathData`, `pathDataToAnchors`, `getPathBoundsFromAnchors`, `inferPathAnchorPointType`
|
||||
- `src/font-utils.ts` — `cssFontFamily`: CSS font-family string builder
|
||||
- `src/node-helpers.ts` — `isBadgeOverlayNode`, `sanitizeName` (PascalCase conversion)
|
||||
- `src/design-md-parser.ts` — `parseDesignMd`, `generateDesignMd`, `designMdColorsToVariables`, `extractDesignMdFromDocument`
|
||||
- `src/constants.ts` — Canvas rendering constants (zoom limits, colors, snap thresholds, pen tool sizes, guide styling)
|
||||
- `src/id.ts` — `generateId` (nanoid wrapper)
|
||||
- `src/layout/engine.ts` — Auto-layout computation: `resolvePadding`, `getNodeWidth`, `getNodeHeight`, `computeLayoutPositions`, `inferLayout`, `fitContentWidth`, `fitContentHeight`, `isNodeVisible`, `setRootChildrenProvider`, `getRootFillWidthFallback`
|
||||
- `src/layout/text-measure.ts` — Text measurement: `estimateTextWidth`, `estimateTextWidthPrecise`, `estimateTextHeight`, `resolveTextContent`, `parseSizing`, `defaultLineHeight`, `hasCjkText`, `isCjkCodePoint`, `countWrappedLinesFallback`, `setWrappedLineCounter`
|
||||
- `src/layout/normalize-tree.ts` — `normalizeTreeLayout`: infers missing layout mode on frames, strips child x/y in layout containers
|
||||
- `src/layout/unwrap-fake-phone-mockup.ts` — `unwrapFakePhoneMockups`: repairs AI-generated fake phone mockup frames
|
||||
- `src/layout/strip-redundant-section-fills.ts` — `stripRedundantSectionFills`: removes redundant dark fills from section containers
|
||||
- `src/normalize/normalize-stroke-fill-schema.ts` — `normalizeStrokeFillSchema`: repairs AI-generated stroke/fill schema violations
|
||||
- `src/variables/resolve.ts` — `isVariableRef`, `getDefaultTheme`, `resolveVariableRef`, `resolveColorRef`, `resolveNumericRef`, `resolveNodeForCanvas`
|
||||
- `src/variables/replace-refs.ts` — `replaceVariableRefsInTree`: recursively rename/delete `$variable` refs in node trees
|
||||
- `src/merge/node-diff.ts` — `diffDocuments`: one-direction diff producing `NodePatch[]` (add/remove/modify/move)
|
||||
- `src/merge/node-merge.ts` — `mergeDocuments`: pure 3-way merge of PenDocument trees, returns `MergeResult` with conflicts
|
||||
- `src/merge/merge-helpers.ts` — Shared helpers for diff/merge (node indexing, field comparison)
|
||||
|
||||
## Key patterns
|
||||
|
||||
- All tree operations are pure functions returning new references (structural sharing)
|
||||
- `$variable` refs are preserved in the document; resolution happens at render time via `resolveNodeForCanvas()`
|
||||
- Layout engine resolves `fill_container`/`fit_content` sizing and computes absolute positions
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun --bun vitest run packages/pen-core/src/__tests__/
|
||||
```
|
||||
21
packages/pen-core/LICENSE
Normal file
21
packages/pen-core/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,89 +1,232 @@
|
|||
# @zseven-w/pen-core
|
||||
|
||||
Core document operations for [OpenPencil](https://github.com/nicepkg/openpencil) — tree manipulation, layout engine, design variables, boolean path operations, and more.
|
||||
Core document operations for [OpenPencil](https://github.com/ZSeven-W/openpencil) — tree manipulation, layout engine, design variables, boolean path operations, 3-way merge, and more.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @zseven-w/pen-core
|
||||
# or
|
||||
bun add @zseven-w/pen-core
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
`pen-core` is the foundation layer of the OpenPencil stack. It provides pure functions for every document operation — tree CRUD, auto-layout computation, variable resolution, SVG path booleans, and document normalization. All operations are immutable (structural sharing) and framework-free.
|
||||
|
||||
## Features
|
||||
|
||||
### Document Tree Operations
|
||||
|
||||
Create, query, and mutate the document tree:
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import {
|
||||
createEmptyDocument,
|
||||
findNodeInTree,
|
||||
findParentInTree,
|
||||
insertNodeInTree,
|
||||
removeNodeFromTree,
|
||||
updateNodeInTree,
|
||||
deepCloneNode,
|
||||
flattenNodes,
|
||||
isDescendantOf,
|
||||
getNodeBounds,
|
||||
} from '@zseven-w/pen-core';
|
||||
|
||||
const doc = createEmptyDocument();
|
||||
const node = findNodeInTree(doc.children, 'node-id');
|
||||
const parent = findParentInTree(doc.children, 'node-id');
|
||||
const flat = flattenNodes(doc.children); // all nodes in flat array
|
||||
```
|
||||
|
||||
### Node Cloning
|
||||
|
||||
Deep clone nodes with optional ID regeneration:
|
||||
|
||||
```typescript
|
||||
import { deepCloneNode, cloneNodeWithNewIds, cloneNodesWithNewIds } from '@zseven-w/pen-core';
|
||||
|
||||
const clone = deepCloneNode(node); // preserve IDs
|
||||
const fresh = cloneNodeWithNewIds(node); // new IDs for all descendants
|
||||
const batch = cloneNodesWithNewIds([a, b, c]); // batch clone
|
||||
```
|
||||
|
||||
### Multi-Page Support
|
||||
|
||||
```ts
|
||||
import { getActivePage, getActivePageChildren, migrateToPages } from '@zseven-w/pen-core';
|
||||
```typescript
|
||||
import {
|
||||
getActivePage,
|
||||
getActivePageChildren,
|
||||
setActivePageChildren,
|
||||
migrateToPages,
|
||||
ensureDocumentNodeIds,
|
||||
} from '@zseven-w/pen-core';
|
||||
|
||||
// Migrate a single-page document to multi-page format
|
||||
const multiPageDoc = migrateToPages(doc);
|
||||
```
|
||||
|
||||
### Layout Engine
|
||||
|
||||
Automatic layout computation with auto-sizing, padding, and gap support:
|
||||
Flexbox-like auto-layout computation supporting `fill_container`, `fit_content`, gap, padding, and alignment:
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import {
|
||||
inferLayout,
|
||||
computeLayoutPositions,
|
||||
inferLayout,
|
||||
fitContentWidth,
|
||||
fitContentHeight,
|
||||
resolvePadding,
|
||||
getNodeWidth,
|
||||
getNodeHeight,
|
||||
isNodeVisible,
|
||||
} from '@zseven-w/pen-core';
|
||||
|
||||
// Compute absolute positions for all children in a layout container
|
||||
const positions = computeLayoutPositions(frame, frame.children);
|
||||
|
||||
// Infer layout direction from child arrangement
|
||||
const layout = inferLayout(children); // 'horizontal' | 'vertical' | 'none'
|
||||
```
|
||||
|
||||
### Text Measurement
|
||||
|
||||
Estimate text dimensions for layout without a browser DOM:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
estimateTextWidth,
|
||||
estimateTextWidthPrecise,
|
||||
estimateTextHeight,
|
||||
resolveTextContent,
|
||||
hasCjkText,
|
||||
defaultLineHeight,
|
||||
} from '@zseven-w/pen-core';
|
||||
|
||||
const width = estimateTextWidth('Hello World', 16, 400); // font size, weight
|
||||
const height = estimateTextHeight(textNode, containerWidth);
|
||||
const isCjk = hasCjkText('こんにちは'); // true
|
||||
```
|
||||
|
||||
### Design Variables
|
||||
|
||||
Resolve `$variable` references against theme axes:
|
||||
Resolve `$variable` references against document variables and theme axes:
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import {
|
||||
isVariableRef,
|
||||
resolveVariableRef,
|
||||
resolveNodeForCanvas,
|
||||
replaceVariableRefsInTree,
|
||||
getDefaultTheme,
|
||||
} from '@zseven-w/pen-core';
|
||||
|
||||
isVariableRef('$primary'); // true
|
||||
|
||||
// Resolve all $refs in a node tree for rendering
|
||||
const resolved = resolveNodeForCanvas(node, doc.variables, doc.themes);
|
||||
|
||||
// Rename $old-name → $new-name across entire tree
|
||||
replaceVariableRefsInTree(children, 'old-name', 'new-name');
|
||||
```
|
||||
|
||||
### Boolean Path Operations
|
||||
|
||||
Union, subtract, intersect, and exclude paths via Paper.js:
|
||||
|
||||
```ts
|
||||
import { executeBooleanOp, BooleanOpType } from '@zseven-w/pen-core';
|
||||
```
|
||||
```typescript
|
||||
import { executeBooleanOp, canBooleanOp, BooleanOpType } from '@zseven-w/pen-core';
|
||||
|
||||
### Text Measurement
|
||||
|
||||
Estimate text dimensions for layout without a browser:
|
||||
|
||||
```ts
|
||||
import { estimateTextWidth, estimateTextHeight } from '@zseven-w/pen-core';
|
||||
if (canBooleanOp(selectedNodes)) {
|
||||
const result = executeBooleanOp(selectedNodes, BooleanOpType.Union);
|
||||
}
|
||||
```
|
||||
|
||||
### Document Normalization
|
||||
|
||||
Sanitize and fix documents imported from external sources:
|
||||
Sanitize and fix documents from external sources (Figma imports, AI generation):
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import { normalizePenDocument } from '@zseven-w/pen-core';
|
||||
|
||||
const cleaned = normalizePenDocument(rawDoc);
|
||||
// Fixes: fill type "color" → "solid", gradient stop position → offset,
|
||||
// sizing strings, padding arrays. Preserves $variable refs.
|
||||
```
|
||||
|
||||
### Layout Normalization
|
||||
|
||||
Repair AI-generated layout issues:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
normalizeTreeLayout,
|
||||
unwrapFakePhoneMockups,
|
||||
stripRedundantSectionFills,
|
||||
normalizeStrokeFillSchema,
|
||||
} from '@zseven-w/pen-core';
|
||||
|
||||
// Infer missing layout modes, strip child x/y in layout containers
|
||||
normalizeTreeLayout(rootNode);
|
||||
```
|
||||
|
||||
### 3-Way Document Merge
|
||||
|
||||
Diff and merge document trees for collaborative editing and git integration:
|
||||
|
||||
```typescript
|
||||
import { diffDocuments, mergeDocuments } from '@zseven-w/pen-core';
|
||||
|
||||
// One-direction diff: base → current
|
||||
const patches = diffDocuments(base, current);
|
||||
// patches: NodePatch[] — add, remove, modify, move
|
||||
|
||||
// 3-way merge: base + ours + theirs
|
||||
const result = mergeDocuments(base, ours, theirs);
|
||||
// result: { document, conflicts }
|
||||
```
|
||||
|
||||
### Design.md Parser
|
||||
|
||||
Parse and generate design specification documents:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
parseDesignMd,
|
||||
generateDesignMd,
|
||||
designMdColorsToVariables,
|
||||
extractDesignMdFromDocument,
|
||||
} from '@zseven-w/pen-core';
|
||||
```
|
||||
|
||||
### Path Anchors
|
||||
|
||||
Convert between anchor point representation and SVG path data:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
anchorsToPathData,
|
||||
pathDataToAnchors,
|
||||
getPathBoundsFromAnchors,
|
||||
inferPathAnchorPointType,
|
||||
} from '@zseven-w/pen-core';
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Category | Key Functions |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **Tree CRUD** | `findNodeInTree`, `insertNodeInTree`, `removeNodeFromTree`, `updateNodeInTree`, `flattenNodes` |
|
||||
| **Cloning** | `deepCloneNode`, `cloneNodeWithNewIds`, `cloneNodesWithNewIds` |
|
||||
| **Pages** | `getActivePage`, `migrateToPages`, `ensureDocumentNodeIds` |
|
||||
| **Layout** | `computeLayoutPositions`, `inferLayout`, `fitContentWidth`, `fitContentHeight` |
|
||||
| **Text** | `estimateTextWidth`, `estimateTextHeight`, `hasCjkText` |
|
||||
| **Variables** | `resolveVariableRef`, `resolveNodeForCanvas`, `replaceVariableRefsInTree` |
|
||||
| **Boolean** | `executeBooleanOp`, `canBooleanOp` |
|
||||
| **Normalize** | `normalizePenDocument`, `normalizeTreeLayout` |
|
||||
| **Merge** | `diffDocuments`, `mergeDocuments` |
|
||||
| **IDs** | `generateId` |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](./LICENSE)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-core",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
|
||||
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-core",
|
||||
"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": "packages/pen-core"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
|
|
|
|||
45
packages/pen-engine/CLAUDE.md
Normal file
45
packages/pen-engine/CLAUDE.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# pen-engine
|
||||
|
||||
Headless design engine -- framework-free document, selection, history, viewport, and spatial indexing.
|
||||
|
||||
## Structure
|
||||
|
||||
### Core (`src/core/`)
|
||||
|
||||
- `design-engine.ts` — `DesignEngine` class: composes all managers, exposes high-level API (`loadDocument`, `addNode`, `updateNode`, `deleteNode`, `moveNode`, `select`, `undo`, `redo`, `setTool`, `generateCode`, `batch`, `on`/`off` events). Zero DOM/React/Zustand dependencies
|
||||
- `document-manager.ts` — `DocumentManager`: immutable PenDocument tree mutations with history integration. Methods: `getDocument`, `loadDocument`, `addNode`, `updateNode`, `deleteNode`, `moveNode`, `findNode`, `findParent`, `duplicateNode`
|
||||
- `history-manager.ts` — `HistoryManager`: framework-agnostic undo/redo stack with batch support and debouncing (300ms). Methods: `push`, `undo`, `redo`, `clear`, `beginBatch`, `endBatch`
|
||||
- `selection-manager.ts` — `SelectionManager`: immutable selection state. Methods: `select`, `clearSelection`, `getSelection`, `getActiveId`, `setHoveredId`
|
||||
- `page-manager.ts` — `PageManager`: multi-page lifecycle. Methods: `getActivePage`, `setActivePage`, `addPage`, `removePage`, `renamePage`, `duplicatePage`, `reorderPage`
|
||||
- `variable-manager.ts` — `VariableManager`: design variable CRUD with theme support. Methods: `setVariable`, `removeVariable`, `renameVariable`, `setThemes`
|
||||
- `viewport-controller.ts` — `ViewportController`: zoom/pan math, coordinate transforms. Methods: `setViewport`, `screenToScene`, `sceneToScreen`, `zoomTo`, `zoomToFit`
|
||||
- `event-emitter.ts` — `TypedEventEmitter<Events>`: generic typed pub/sub with `on`/`off`/`emit`/`dispose`
|
||||
- `node-creator.ts` — `createNodeForTool(tool, x, y, w, h)`: factory for default PenNodes per tool type; `isDrawingTool(tool)` check
|
||||
- `svg-parser.ts` — `parseSvgToNodes(svgString)`: isomorphic SVG to PenNode[] converter (DOMParser in browser, regex fallback in Node.js)
|
||||
- `spatial-index.ts` — `EngineSpatialIndex`: wraps pen-renderer's `SpatialIndex` for engine-level hit testing. Methods: `rebuild`, `hitTest`, `searchRect`, `hitTestNode`
|
||||
- `constants.ts` — Engine constants: `DEFAULT_MAX_HISTORY`, `HISTORY_DEBOUNCE_MS`, `MIN_DRAW_SIZE`, `DRAG_THRESHOLD`, `HANDLE_HIT_RADIUS`, `HANDLE_CURSORS`
|
||||
|
||||
### Browser adapter (`src/browser/`)
|
||||
|
||||
- `canvas-bindings.ts` — `attachCanvas(engine, canvasEl, options)`: loads CanvasKit WASM, creates `CanvasBinding` for GPU rendering with auto-rerender on engine events
|
||||
- `canvas-renderer.ts` — `CanvasRenderer`: internal class managing CanvasKit surface, render nodes, and redraw loop
|
||||
- `text-edit-overlay.ts` — `TextEditOverlayOptions`: DOM textarea overlay for inline text editing
|
||||
- `interaction/` — Mouse/keyboard event handlers:
|
||||
- `interaction-controller.ts` — `attachInteraction(engine, canvasEl)`: binds all pointer/keyboard events
|
||||
- `select-handler.ts` — Click/drag selection, multi-select, entered-frame navigation
|
||||
- `draw-handler.ts` — Shape drawing (rectangle, ellipse, frame, line, polygon)
|
||||
- `resize-handler.ts` — Selection handle resize with aspect ratio support
|
||||
- `pen-tool-handler.ts` — Bezier pen tool for path creation
|
||||
- `arc-handler.ts` — Arc/sweep angle editing for ellipses
|
||||
|
||||
## Key exports (from `src/index.ts`)
|
||||
|
||||
`DesignEngine`, `TypedEventEmitter`, `HistoryManager`, `DocumentManager`, `SelectionManager`, `PageManager`, `VariableManager`, `ViewportController`, `EngineSpatialIndex`, `createNodeForTool`, `isDrawingTool`, `parseSvgToNodes`
|
||||
|
||||
Browser adapter (from `src/browser.ts`): `attachCanvas`, `attachInteraction`, `CanvasBinding`, `AttachCanvasOptions`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun --bun vitest run packages/pen-engine/src/__tests__/
|
||||
```
|
||||
21
packages/pen-engine/LICENSE
Normal file
21
packages/pen-engine/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
206
packages/pen-engine/README.md
Normal file
206
packages/pen-engine/README.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# @zseven-w/pen-engine
|
||||
|
||||
Headless design engine for [OpenPencil](https://github.com/ZSeven-W/openpencil) — framework-free document management, selection, history, viewport, and spatial queries. Build your own design tool UI on top of this engine.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @zseven-w/pen-engine
|
||||
# or
|
||||
bun add @zseven-w/pen-engine
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
`pen-engine` is the core runtime that powers OpenPencil's editor. It manages the entire document lifecycle without any framework dependency — React, Vue, Svelte, or vanilla JS all work. The optional `browser.ts` entry adds GPU-accelerated canvas rendering via CanvasKit/Skia.
|
||||
|
||||
```
|
||||
DesignEngine
|
||||
|- DocumentManager Node CRUD, tree operations
|
||||
|- SelectionManager Multi-select, hover tracking
|
||||
|- HistoryManager Undo/redo with debounce + batch
|
||||
|- PageManager Multi-page support
|
||||
|- VariableManager Design variables ($refs)
|
||||
|- ViewportController Zoom, pan, coordinate transforms
|
||||
|- SpatialIndex R-tree for hit testing & spatial queries
|
||||
|- EventEmitter Typed event system
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { DesignEngine } from '@zseven-w/pen-engine';
|
||||
|
||||
const engine = new DesignEngine();
|
||||
|
||||
// Load or create a document
|
||||
engine.loadDocument(myDocument);
|
||||
|
||||
// Add a node
|
||||
engine.addNode(null, {
|
||||
id: 'frame-1',
|
||||
type: 'frame',
|
||||
name: 'Header',
|
||||
width: 1200,
|
||||
height: 80,
|
||||
layout: 'horizontal',
|
||||
});
|
||||
|
||||
// Select, undo, inspect
|
||||
engine.select(['frame-1']);
|
||||
engine.undo();
|
||||
console.log(engine.getDocument());
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Document Operations
|
||||
|
||||
Create, query, and mutate the node tree:
|
||||
|
||||
```typescript
|
||||
engine.addNode(parentId, node, index?);
|
||||
engine.updateNode(id, { fill: [{ type: 'solid', color: '#FF0000' }] });
|
||||
engine.removeNode(id);
|
||||
engine.moveNode(id, newParentId, index);
|
||||
engine.duplicateNode(id);
|
||||
engine.groupNodes(['node-1', 'node-2']);
|
||||
engine.ungroupNode(groupId);
|
||||
engine.getNodeById(id);
|
||||
```
|
||||
|
||||
### Selection & Hover
|
||||
|
||||
```typescript
|
||||
engine.select(['node-1', 'node-2']);
|
||||
engine.clearSelection();
|
||||
engine.getSelection(); // string[]
|
||||
engine.setHoveredId('node-3');
|
||||
engine.getHoveredId(); // string | null
|
||||
```
|
||||
|
||||
### History (Undo / Redo)
|
||||
|
||||
Structural history with debouncing and batch support:
|
||||
|
||||
```typescript
|
||||
engine.undo();
|
||||
engine.redo();
|
||||
engine.canUndo; // boolean
|
||||
engine.canRedo; // boolean
|
||||
|
||||
// Batch multiple mutations into a single history entry
|
||||
engine.batch(() => {
|
||||
engine.updateNode('a', { x: 100 });
|
||||
engine.updateNode('b', { x: 200 });
|
||||
});
|
||||
```
|
||||
|
||||
### Viewport
|
||||
|
||||
Pan, zoom, and coordinate conversion:
|
||||
|
||||
```typescript
|
||||
engine.setViewport(zoom, panX, panY);
|
||||
engine.zoomToRect(x, y, w, h, containerW, containerH);
|
||||
engine.getContentBounds(); // { x, y, w, h } | null
|
||||
engine.screenToScene(screenX, screenY); // { x, y }
|
||||
engine.sceneToScreen(sceneX, sceneY); // { x, y }
|
||||
```
|
||||
|
||||
### Hit Testing (Spatial Index)
|
||||
|
||||
R-tree backed queries for click and marquee selection:
|
||||
|
||||
```typescript
|
||||
engine.hitTest(x, y); // PenNode | null
|
||||
engine.searchRect(x, y, w, h); // PenNode[]
|
||||
```
|
||||
|
||||
### Multi-Page
|
||||
|
||||
```typescript
|
||||
engine.addPage(); // returns pageId
|
||||
engine.removePage(pageId);
|
||||
engine.setActivePage(pageId);
|
||||
engine.getActivePage();
|
||||
```
|
||||
|
||||
### Design Variables
|
||||
|
||||
```typescript
|
||||
engine.setVariable('primary', { type: 'color', value: '#2563EB' });
|
||||
engine.removeVariable('primary');
|
||||
engine.renameVariable('primary', 'brand');
|
||||
engine.resolveVariable('$primary'); // '#2563EB'
|
||||
```
|
||||
|
||||
### SVG Import
|
||||
|
||||
Isomorphic SVG parser (DOM in browser, regex fallback in Node.js):
|
||||
|
||||
```typescript
|
||||
import { parseSvgToNodes } from '@zseven-w/pen-engine';
|
||||
|
||||
const nodes = parseSvgToNodes(svgString, 400);
|
||||
engine.addNode(null, nodes[0]);
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
Typed event system for reactive UI binding:
|
||||
|
||||
```typescript
|
||||
const unsub = engine.on('document:change', (doc) => {
|
||||
/* re-render */
|
||||
});
|
||||
engine.on('selection:change', (ids) => {
|
||||
/* update UI */
|
||||
});
|
||||
engine.on('viewport:change', (viewport) => {
|
||||
/* update zoom indicator */
|
||||
});
|
||||
unsub(); // unsubscribe
|
||||
```
|
||||
|
||||
### Browser Canvas (Optional)
|
||||
|
||||
GPU-accelerated rendering via CanvasKit/Skia — import from `@zseven-w/pen-engine/browser`:
|
||||
|
||||
```typescript
|
||||
import { attachCanvas, attachInteraction } from '@zseven-w/pen-engine/browser';
|
||||
|
||||
const binding = await attachCanvas(engine, canvasElement);
|
||||
const detach = attachInteraction(engine, canvasElement);
|
||||
|
||||
// Later
|
||||
binding.dispose();
|
||||
detach();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------- | ---------------------- |
|
||||
| `loadDocument(doc)` | Load a PenDocument |
|
||||
| `getDocument()` | Get current document |
|
||||
| `createDocument()` | Create empty document |
|
||||
| `addNode(parent, node, index?)` | Insert node |
|
||||
| `updateNode(id, updates)` | Partial update |
|
||||
| `removeNode(id)` | Delete node + children |
|
||||
| `moveNode(id, parent, index)` | Reparent node |
|
||||
| `duplicateNode(id)` | Deep clone |
|
||||
| `groupNodes(ids)` | Group into frame |
|
||||
| `ungroupNode(id)` | Dissolve group |
|
||||
| `select(ids)` | Set selection |
|
||||
| `undo()` / `redo()` | History navigation |
|
||||
| `batch(fn)` | Batch mutations |
|
||||
| `setViewport(z, x, y)` | Set viewport |
|
||||
| `hitTest(x, y)` | Point query |
|
||||
| `searchRect(x, y, w, h)` | Area query |
|
||||
| `importSVG(svg, parent?)` | Parse and insert SVG |
|
||||
| `dispose()` | Clean up resources |
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
|
@ -1,7 +1,21 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-engine",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Headless design engine for OpenPencil — zero framework dependencies",
|
||||
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-engine",
|
||||
"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": "packages/pen-engine"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
|
|
|
|||
37
packages/pen-figma/CLAUDE.md
Normal file
37
packages/pen-figma/CLAUDE.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# pen-figma
|
||||
|
||||
Figma `.fig` binary file parser and converter to PenDocument format.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/fig-parser.ts` — `parseFigFile(buffer)`: binary `.fig` file parser using kiwi-schema, supports zstd/zip compression, embedded images. Returns `FigmaDecodedFile`
|
||||
- `src/figma-node-mapper.ts` — `figmaToPenDocument`, `figmaAllPagesToPenDocument`, `getFigmaPages`, `figmaNodeChangesToPenNodes`: top-level conversion from decoded Figma data to PenDocument/PenNode arrays. Resolves style references (fill, stroke, text, effect)
|
||||
- `src/figma-tree-builder.ts` — `buildTree`, `buildTreeForClipboard`, `collectComponents`, `collectSymbolTree`, `guidToString`, `isUserPage`: builds parent-child tree from flat Figma node list
|
||||
- `src/figma-clipboard.ts` — `isFigmaClipboardHtml`, `extractFigmaClipboardData`, `figmaClipboardToNodes`: handles paste from Figma clipboard (base64-encoded `.fig` data in HTML)
|
||||
- `src/figma-types.ts` — Internal Figma types: `FigmaDecodedFile`, `FigmaNodeChange`, `FigmaGUID`, `FigmaColor`, `FigmaMatrix`, `FigmaPaintType`, `FigmaImportLayoutMode`
|
||||
- `src/figma-image-resolver.ts` — `resolveImageBlobs`: resolves image blob references from decoded `.fig` data to data URLs
|
||||
- `src/figma-color-utils.ts` — Color space conversion utilities (Figma 0-1 floats to hex)
|
||||
- `src/converters/` — Node type converters (dispatcher + per-type modules):
|
||||
- `index.ts` — `convertNode` dispatcher, `convertChildren` recursive walker
|
||||
- `common.ts` — Shared helpers: `commonProps`, `extractPosition`, `extractRotation`, `mapCornerRadius`, `resolveWidth/Height`, `scaleTreeChildren`, `collectImageBlobs`, `setIconLookup`, `lookupIconByName`
|
||||
- `frame-converter.ts` — `convertFrame`, `convertGroup`, `convertComponent`, `convertInstance`
|
||||
- `shape-converter.ts` — `convertRectangle`, `convertEllipse`, `convertLine`
|
||||
- `text-converter.ts` — `convertText`
|
||||
- `path-converter.ts` — `convertVector` (vector, star, polygon, boolean operation)
|
||||
- `image-converter.ts` — Image node conversion
|
||||
- `src/figma-fill-mapper.ts` — Maps Figma paint arrays to `PenFill[]`
|
||||
- `src/figma-stroke-mapper.ts` — Maps Figma stroke properties to `PenStroke`
|
||||
- `src/figma-effect-mapper.ts` — Maps Figma effects to `PenEffect[]`
|
||||
- `src/figma-layout-mapper.ts` — Maps Figma auto-layout to PenNode layout props (direction, gap, padding, alignment)
|
||||
- `src/figma-text-mapper.ts` — Converts Figma text styles and segments
|
||||
- `src/figma-vector-decoder.ts` — Decodes Figma vector geometry to SVG path data
|
||||
|
||||
## Key exports
|
||||
|
||||
`parseFigFile`, `figmaToPenDocument`, `figmaAllPagesToPenDocument`, `getFigmaPages`, `figmaNodeChangesToPenNodes`, `isFigmaClipboardHtml`, `extractFigmaClipboardData`, `figmaClipboardToNodes`, `resolveImageBlobs`, `setIconLookup`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun --bun vitest run packages/pen-figma/src/converters/__tests__/
|
||||
```
|
||||
21
packages/pen-figma/LICENSE
Normal file
21
packages/pen-figma/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,57 +1,159 @@
|
|||
# @zseven-w/pen-figma
|
||||
|
||||
Figma `.fig` file parser and converter for [OpenPencil](https://github.com/nicepkg/openpencil). Import Figma designs directly into the OpenPencil document model.
|
||||
Figma `.fig` file parser and converter for [OpenPencil](https://github.com/ZSeven-W/openpencil). Import Figma designs directly into the OpenPencil document model — binary file parsing, multi-page support, clipboard paste, and full style conversion.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @zseven-w/pen-figma
|
||||
# or
|
||||
bun add @zseven-w/pen-figma
|
||||
```
|
||||
|
||||
## Features
|
||||
## Overview
|
||||
|
||||
- Parse binary `.fig` files (Kiwi schema + zstd/zip compression)
|
||||
- Convert Figma node trees to `PenDocument`
|
||||
- Multi-page support — import all pages or a single page
|
||||
- Clipboard paste — detect and convert Figma clipboard HTML
|
||||
- Image blob resolution
|
||||
This package handles the complete pipeline from Figma's proprietary binary format to OpenPencil's `PenDocument`:
|
||||
|
||||
```
|
||||
.fig binary → Kiwi schema decode → FigmaNodeChange[] → tree building → PenNode[] → PenDocument
|
||||
```
|
||||
|
||||
It supports:
|
||||
|
||||
- Binary `.fig` files (Kiwi schema + zstd/zip compression)
|
||||
- Figma clipboard HTML (copy from Figma → paste in OpenPencil)
|
||||
- All node types: frames, groups, components, instances, shapes, text, vectors, images
|
||||
- Full style conversion: fills, strokes, effects, gradients, auto-layout, typography
|
||||
|
||||
## Usage
|
||||
|
||||
### Parse a `.fig` file
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import { parseFigFile, figmaAllPagesToPenDocument } from '@zseven-w/pen-figma';
|
||||
|
||||
const buffer = await fs.readFile('design.fig');
|
||||
const figFile = parseFigFile(buffer);
|
||||
const document = figmaAllPagesToPenDocument(figFile);
|
||||
|
||||
console.log(`Imported ${document.pages?.length} pages`);
|
||||
```
|
||||
|
||||
### Single page import
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import { parseFigFile, getFigmaPages, figmaToPenDocument } from '@zseven-w/pen-figma';
|
||||
|
||||
const figFile = parseFigFile(buffer);
|
||||
const pages = getFigmaPages(figFile);
|
||||
|
||||
// Import just the first page
|
||||
const document = figmaToPenDocument(figFile, pages[0]);
|
||||
```
|
||||
|
||||
### Clipboard paste
|
||||
|
||||
```ts
|
||||
Detect and convert Figma clipboard data (when users copy from Figma and paste into OpenPencil):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isFigmaClipboardHtml,
|
||||
extractFigmaClipboardData,
|
||||
figmaClipboardToNodes,
|
||||
} from '@zseven-w/pen-figma';
|
||||
|
||||
if (isFigmaClipboardHtml(html)) {
|
||||
const data = extractFigmaClipboardData(html);
|
||||
const nodes = figmaClipboardToNodes(data);
|
||||
}
|
||||
document.addEventListener('paste', (e) => {
|
||||
const html = e.clipboardData?.getData('text/html');
|
||||
if (html && isFigmaClipboardHtml(html)) {
|
||||
const data = extractFigmaClipboardData(html);
|
||||
const nodes = figmaClipboardToNodes(data);
|
||||
// Insert nodes into document...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Convert individual nodes
|
||||
|
||||
For lower-level access (e.g., incremental sync):
|
||||
|
||||
```typescript
|
||||
import { figmaNodeChangesToPenNodes } from '@zseven-w/pen-figma';
|
||||
|
||||
const penNodes = figmaNodeChangesToPenNodes(figmaNodeChanges, options);
|
||||
```
|
||||
|
||||
### Icon resolution
|
||||
|
||||
Register an icon lookup function for converting Figma component instances to icon nodes:
|
||||
|
||||
```typescript
|
||||
import { setIconLookup } from '@zseven-w/pen-figma';
|
||||
|
||||
setIconLookup((name) => {
|
||||
// Return SVG path data for the icon name, or null
|
||||
return iconRegistry[name]?.path ?? null;
|
||||
});
|
||||
```
|
||||
|
||||
### Image resolution
|
||||
|
||||
Resolve embedded image blob references to data URLs:
|
||||
|
||||
```typescript
|
||||
import { resolveImageBlobs } from '@zseven-w/pen-figma';
|
||||
|
||||
const images = resolveImageBlobs(figFile);
|
||||
// Map<blobHash, dataURL>
|
||||
```
|
||||
|
||||
## Conversion Coverage
|
||||
|
||||
### Node Types
|
||||
|
||||
| Figma Type | PenNode Type | Notes |
|
||||
| ---------- | ------------ | ------------------------------------------- |
|
||||
| FRAME | `frame` | With auto-layout, constraints, clip content |
|
||||
| GROUP | `group` | Preserves child transforms |
|
||||
| COMPONENT | `frame` | Marked as reusable |
|
||||
| INSTANCE | `frame` | Resolved with overrides |
|
||||
| RECTANGLE | `rectangle` | Corner radius (uniform + per-corner) |
|
||||
| ELLIPSE | `ellipse` | Arc support |
|
||||
| LINE | `line` | Stroke properties |
|
||||
| TEXT | `text` | Rich text with styled segments |
|
||||
| VECTOR | `path` | Complex paths, boolean ops, stars, polygons |
|
||||
| IMAGE | `image` | Embedded or referenced |
|
||||
|
||||
### Styles
|
||||
|
||||
| Figma Style | Conversion |
|
||||
| ---------------- | --------------------------------------------------------------------- |
|
||||
| Solid fills | `PenFill` with solid type |
|
||||
| Linear gradients | `PenFill` with gradient stops + angle |
|
||||
| Radial gradients | `PenFill` with center + radius |
|
||||
| Image fills | `PenFill` with image source |
|
||||
| Strokes | `PenStroke` with cap, join, dash |
|
||||
| Drop shadows | `PenEffect` shadow |
|
||||
| Inner shadows | `PenEffect` inner shadow |
|
||||
| Blur | `PenEffect` blur |
|
||||
| Auto-layout | `layout`, `gap`, `padding`, `justifyContent`, `alignItems` |
|
||||
| Text styles | `fontSize`, `fontWeight`, `fontFamily`, `lineHeight`, `letterSpacing` |
|
||||
| Rich text | `StyledTextSegment[]` with per-run styles |
|
||||
|
||||
## API Reference
|
||||
|
||||
| Function | Description |
|
||||
| ------------------------------------- | --------------------------------------------- |
|
||||
| `parseFigFile(buffer)` | Parse binary `.fig` file → `FigmaDecodedFile` |
|
||||
| `figmaAllPagesToPenDocument(file)` | Convert all pages → `PenDocument` |
|
||||
| `figmaToPenDocument(file, page)` | Convert single page → `PenDocument` |
|
||||
| `getFigmaPages(file)` | List available pages |
|
||||
| `figmaNodeChangesToPenNodes(changes)` | Convert raw node changes → `PenNode[]` |
|
||||
| `isFigmaClipboardHtml(html)` | Detect Figma clipboard data |
|
||||
| `extractFigmaClipboardData(html)` | Extract base64 `.fig` from clipboard HTML |
|
||||
| `figmaClipboardToNodes(data)` | Convert clipboard data → `PenNode[]` |
|
||||
| `resolveImageBlobs(file)` | Resolve image references → data URLs |
|
||||
| `setIconLookup(fn)` | Register icon name → SVG path resolver |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](./LICENSE)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-figma",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Figma .fig file parser and converter for OpenPencil",
|
||||
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-figma",
|
||||
"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": "packages/pen-figma"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
|
|
|
|||
78
packages/pen-mcp/CLAUDE.md
Normal file
78
packages/pen-mcp/CLAUDE.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# pen-mcp
|
||||
|
||||
MCP (Model Context Protocol) server for external LLM integration with OpenPencil.
|
||||
|
||||
## Structure
|
||||
|
||||
### Server
|
||||
|
||||
- `src/server.ts` — Standalone MCP server entry point (stdio + HTTP transports). Registers all tool definitions and dispatches to route modules
|
||||
- `src/document-manager.ts` — File I/O and live canvas sync: `openDocument`, `saveDocument`, `resolveDocPath`, `getCachedDocument`, `setSyncUrl`, `getSyncUrl`, `getLiveSyncState`, `fetchLiveSelection`. Supports both `.op` files and `live://canvas` (Electron/web dev server sync)
|
||||
- `src/hooks.ts` — `McpHooks` interface + `configureMcpHooks`/`getMcpHooks`: injectable hooks for role resolution, icon lookup, node sanitization. Web app injects implementations at startup
|
||||
- `src/constants.ts` — `MCP_DEFAULT_PORT` (3100), `PORT_FILE_DIR_NAME`, `PORT_FILE_NAME`, `ICONIFY_API_URL`
|
||||
|
||||
### Tools (`src/tools/`)
|
||||
|
||||
Document tools:
|
||||
|
||||
- `open-document.ts` — `handleOpenDocument`: opens/creates `.op` file or connects to live canvas
|
||||
- `batch-get.ts` — `handleBatchGet`: reads document tree with depth control
|
||||
- `get-selection.ts` — `handleGetSelection`: returns current selection from live canvas
|
||||
- `snapshot-layout.ts` — `handleSnapshotLayout`: captures layout snapshot for comparison
|
||||
- `find-empty-space.ts` — `handleFindEmptySpace`: finds unoccupied canvas regions
|
||||
- `pages.ts` — Page CRUD: `handleAddPage`, `handleRemovePage`, `handleRenamePage`, `handleReorderPage`, `handleDuplicatePage`
|
||||
|
||||
Node tools:
|
||||
|
||||
- `node-crud.ts` — `handleInsertNode`, `handleUpdateNode`, `handleDeleteNode`, `handleMoveNode`, `handleCopyNode`, `handleReplaceNode`, `postProcessNode`
|
||||
- `read-nodes.ts` — `handleReadNodes`: read specific nodes by ID with depth control
|
||||
- `import-svg.ts` — `handleImportSvg`: parse and insert SVG content
|
||||
|
||||
Design tools:
|
||||
|
||||
- `batch-design.ts` — `handleBatchDesign`: single-shot batch design DSL
|
||||
- `design-skeleton.ts` — `handleDesignSkeleton`: phase 1 of layered design (structure)
|
||||
- `design-content.ts` — `handleDesignContent`: phase 2 of layered design (content filling)
|
||||
- `design-refine.ts` — `handleDesignRefine`: phase 3 of layered design (polish)
|
||||
- `design-prompt.ts` — `buildDesignPrompt`, `listPromptSections`: segmented design knowledge prompt
|
||||
- `design-md.ts` — `handleGetDesignMd`, `handleSetDesignMd`, `handleExportDesignMd`
|
||||
- `layered-design-defs.ts` — Tool definitions for the layered design workflow
|
||||
|
||||
Variable/theme tools:
|
||||
|
||||
- `variables.ts` — `handleGetVariables`, `handleSetVariables`, `handleSetThemes`
|
||||
- `theme-presets.ts` — `handleSaveThemePreset`, `handleLoadThemePreset`, `handleListThemePresets`
|
||||
|
||||
Codegen tools:
|
||||
|
||||
- `codegen-plan.ts` — `handleCodegenPlan`: AI-driven component chunking
|
||||
- `codegen-submit.ts` — `handleCodegenSubmit`: per-chunk code generation
|
||||
- `codegen-assemble.ts` — `handleCodegenAssemble`: final code assembly
|
||||
- `codegen-clean.ts` — `handleCodegenClean`: cleanup stale codegen state
|
||||
|
||||
Debug tools:
|
||||
|
||||
- `debug-logs-tail.ts` — Tail debug logs
|
||||
- `debug-screenshot.ts` — Capture canvas screenshot
|
||||
- `debug-validation-report.ts` — Document validation report
|
||||
|
||||
### Routes (`src/routes/`)
|
||||
|
||||
Route modules group tool definitions and dispatch handlers by domain: `document-routes.ts`, `node-routes.ts`, `design-routes.ts`, `variable-routes.ts`, `codegen-routes.ts`, `style-guide-routes.ts`, `style-operations-routes.ts`, `debug-routes.ts`
|
||||
|
||||
### Utils (`src/utils/`)
|
||||
|
||||
- `sanitize.ts` — `sanitizeObject`: deep-cleans objects for safe serialization
|
||||
- `id.ts` — `generateId`: nanoid wrapper
|
||||
- `node-operations.ts` — `readNodeWithDepth`: depth-limited node tree reading
|
||||
- `log-utils.ts` — `SENSITIVE_LOG_PATTERN`, `readDebugTail`, `readLogTail`: log file reading with sensitive content redaction
|
||||
- `design-md-parser.ts` — Design.md parsing (re-exported from pen-core)
|
||||
- `design-md-style-policy.ts` — `buildDesignMdStylePolicy`: generates style policy from design spec
|
||||
- `validate-contract.ts` — `validateContract`: validates codegen chunk contracts
|
||||
- `svg-node-parser.ts` — SVG to PenNode parser (Node.js environment)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun --bun vitest run packages/pen-mcp/src/__tests__/
|
||||
```
|
||||
21
packages/pen-mcp/LICENSE
Normal file
21
packages/pen-mcp/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
160
packages/pen-mcp/README.md
Normal file
160
packages/pen-mcp/README.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# @zseven-w/pen-mcp
|
||||
|
||||
[MCP](https://modelcontextprotocol.io/) server for [OpenPencil](https://github.com/ZSeven-W/openpencil) — enables Claude, GPT, Gemini, and other LLMs to read, create, and modify designs through a standard tool protocol.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @zseven-w/pen-mcp
|
||||
# or
|
||||
bun add @zseven-w/pen-mcp
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
`pen-mcp` exposes OpenPencil's full editing API as MCP tools. External AI agents can open documents, inspect the canvas, insert/update/delete nodes, and generate complete designs — all through structured tool calls.
|
||||
|
||||
Three workflows are supported:
|
||||
|
||||
| Workflow | Tools | Best for |
|
||||
| --------------- | ---------------------------------------------------------- | ------------------------------------ |
|
||||
| **Single-shot** | `insert_node`, `batch_design` | Quick edits, single components |
|
||||
| **Layered** | `design_skeleton` → `design_content` × N → `design_refine` | Full-page designs with high fidelity |
|
||||
| **CRUD** | `batch_get` → `update_node` / `delete_node` | Reading & modifying existing content |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run as stdio MCP server (for Claude Desktop, Cursor, etc.)
|
||||
npx @zseven-w/pen-mcp
|
||||
|
||||
# Or connect to a running OpenPencil instance
|
||||
op mcp:dev
|
||||
```
|
||||
|
||||
### Claude Desktop Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openpencil": {
|
||||
"command": "npx",
|
||||
"args": ["@zseven-w/pen-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Document & Read Tools
|
||||
|
||||
| Tool | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `open_document` | Open an `.op` file or connect to the live Electron canvas. Always call first. |
|
||||
| `batch_get` | Search and read nodes by type, name regex, or specific IDs. Controls read depth for nested content. |
|
||||
| `get_selection` | Get the currently selected nodes on the live canvas. |
|
||||
| `snapshot_layout` | Get a compact bounding-box layout tree — useful for spatial understanding. |
|
||||
| `find_empty_space` | Find available canvas space in a given direction for placing new content. |
|
||||
| `get_design_prompt` | Retrieve segmented design knowledge (schema, layout, roles, text, style, icons, examples). |
|
||||
|
||||
### Node CRUD Tools
|
||||
|
||||
| Tool | Description |
|
||||
| -------------- | ----------------------------------------------------------------------------------- |
|
||||
| `insert_node` | Insert a new node with full PenNode data. Supports `postProcess` for auto-defaults. |
|
||||
| `update_node` | Shallow-merge properties into an existing node. |
|
||||
| `delete_node` | Delete a node and all its children. |
|
||||
| `move_node` | Reparent a node to a new container. |
|
||||
| `copy_node` | Deep-clone a node with new IDs under a target parent. |
|
||||
| `replace_node` | Replace a node entirely with new data at the same position. |
|
||||
| `import_svg` | Import a local SVG file as editable PenNodes. |
|
||||
|
||||
### Batch Design DSL
|
||||
|
||||
`batch_design` accepts a compact DSL — one operation per line:
|
||||
|
||||
```
|
||||
root=I(null, { "type": "frame", "name": "Page", "width": 1200, ... })
|
||||
header=I(root, { "type": "frame", "name": "Header", ... })
|
||||
U(header, { "fill": [{ "type": "solid", "color": "#1A1A2E" }] })
|
||||
logo=C("existing-logo", header, { "x": 24 })
|
||||
M("floating-btn", header)
|
||||
D("old-section")
|
||||
```
|
||||
|
||||
| Op | Syntax | Description |
|
||||
| ----- | ------------------------------------------ | ----------------- |
|
||||
| **I** | `binding=I(parent, { data })` | Insert node |
|
||||
| **U** | `U(path, { updates })` | Update properties |
|
||||
| **C** | `binding=C(source, parent, { overrides })` | Copy node |
|
||||
| **R** | `binding=R(path, { newData })` | Replace node |
|
||||
| **M** | `M(nodeId, parent, index?)` | Move node |
|
||||
| **D** | `D(nodeId)` | Delete node |
|
||||
|
||||
### Layered Generation Workflow
|
||||
|
||||
For high-fidelity multi-section designs:
|
||||
|
||||
```
|
||||
1. design_skeleton → Create root frame + section placeholders
|
||||
2. design_content → Fill each section with content nodes (call per section)
|
||||
3. design_refine → Run full-tree validation and auto-fixes
|
||||
```
|
||||
|
||||
### Page Management
|
||||
|
||||
| Tool | Description |
|
||||
| ---------------- | ------------------------------------------ |
|
||||
| `add_page` | Add a new page to the document |
|
||||
| `remove_page` | Remove a page (cannot remove the last one) |
|
||||
| `rename_page` | Rename a page |
|
||||
| `reorder_page` | Move a page to a new index |
|
||||
| `duplicate_page` | Deep-clone a page with new IDs |
|
||||
|
||||
### Post-Processing
|
||||
|
||||
All creation tools support `postProcess=true` for automatic:
|
||||
|
||||
- Semantic role defaults (button padding, input height, card radius, etc.)
|
||||
- Icon name → SVG path resolution (Lucide icon set)
|
||||
- Card row equalization in horizontal layouts
|
||||
- Text height estimation
|
||||
- Frame height expansion when content overflows
|
||||
- `clipContent` auto-addition for frames with `cornerRadius` + images
|
||||
|
||||
## Design Prompt Sections
|
||||
|
||||
`get_design_prompt(section)` returns focused subsets of design knowledge:
|
||||
|
||||
| Section | Content |
|
||||
| ------------ | ------------------------------------------------------------------------- |
|
||||
| `schema` | PenNode type definitions and property reference |
|
||||
| `layout` | Flexbox layout engine rules (gap, padding, justify, align) |
|
||||
| `roles` | Semantic roles and their auto-defaults (button, input, card, navbar, ...) |
|
||||
| `text` | Typography rules, CJK support, copywriting guidelines |
|
||||
| `style` | Visual style policy (colors, fonts, aesthetic) |
|
||||
| `icons` | Feather/Lucide icon naming conventions |
|
||||
| `examples` | Complete design examples with DSL |
|
||||
| `guidelines` | Design tips (cards, inputs, phone mockups, hero sections) |
|
||||
| `planning` | Layered workflow guide with section decomposition rules |
|
||||
|
||||
## Live Canvas Sync
|
||||
|
||||
When connected to a running OpenPencil desktop app, changes made via MCP tools appear on the canvas in real-time. The sync is bidirectional — user edits on the canvas are reflected in subsequent `batch_get` / `snapshot_layout` calls.
|
||||
|
||||
## Programmatic Usage
|
||||
|
||||
```typescript
|
||||
import { configureMcpHooks, MCP_DEFAULT_PORT } from '@zseven-w/pen-mcp';
|
||||
|
||||
// Configure custom hooks (optional)
|
||||
configureMcpHooks({
|
||||
onDocumentOpen: (path) => console.log(`Opened: ${path}`),
|
||||
onNodeInsert: (node) => console.log(`Inserted: ${node.id}`),
|
||||
});
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
|
@ -1,7 +1,21 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-mcp",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "MCP server, document manager, and tools for OpenPencil",
|
||||
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-mcp",
|
||||
"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": "packages/pen-mcp"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -261,10 +261,16 @@ async function pushLiveDocument(doc: PenDocument): Promise<void> {
|
|||
const syncUrl = cachedUrl ?? (await getSyncUrl());
|
||||
if (!syncUrl) return;
|
||||
try {
|
||||
const body = JSON.stringify({ document: doc });
|
||||
const bodyBytes = new TextEncoder().encode(body).byteLength;
|
||||
await fetch(`${syncUrl}/api/mcp/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document: doc }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openpencil-client-id': 'mcp-server:live-canvas',
|
||||
'x-openpencil-body-bytes': String(bodyBytes),
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch {
|
||||
// Network error — Electron might have quit between check and request
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export const DESIGN_TOOL_DEFINITIONS = [
|
|||
' binding=C(sourceId, parent, { overrides }) — Copy node\n' +
|
||||
' binding=R(path, { ...newNodeData }) — Replace node\n' +
|
||||
' M(nodeId, parent, index?) — Move node\n' +
|
||||
' D(nodeId) — Delete node\n' +
|
||||
' D(nodeId) — Delete node (use batch_get to find IDs first)\n' +
|
||||
'Use null for root-level parent. Reference previous bindings by name. ' +
|
||||
'Path expressions support binding+"/ childId" for nested access. ' +
|
||||
'Always set postProcess=true when generating designs for best visual quality.',
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ export const DOCUMENT_TOOL_DEFINITIONS = [
|
|||
{
|
||||
name: 'batch_get',
|
||||
description:
|
||||
'Search and read nodes. With no patterns/nodeIds, returns top-level children. Search by type/name regex, or read specific IDs. ' +
|
||||
'Search and read nodes from the document. ALWAYS call this first before update_node or delete_node to find the correct node IDs. ' +
|
||||
'With no patterns/nodeIds, returns top-level children (use this to see the current page structure). ' +
|
||||
'Search by type/name regex, or read specific IDs. ' +
|
||||
'readDepth controls how deep children are included in results (default 1, use higher to see nested structure). ' +
|
||||
'Returns nodes with children truncated to "..." beyond readDepth.',
|
||||
inputSchema: {
|
||||
|
|
@ -100,7 +102,8 @@ export const DOCUMENT_TOOL_DEFINITIONS = [
|
|||
{
|
||||
name: 'snapshot_layout',
|
||||
description:
|
||||
'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
|
||||
'Get the hierarchical bounding box layout tree of the document. ' +
|
||||
'Use this to understand the current page structure, spatial arrangement, and node hierarchy before making changes.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,11 @@ export const NODE_TOOL_DEFINITIONS = [
|
|||
},
|
||||
{
|
||||
name: 'delete_node',
|
||||
description: 'Delete a node (and all its children) from an .op file.',
|
||||
description:
|
||||
'Delete a node (and all its children) from the document. ' +
|
||||
'Use this when the user asks to remove, delete, or clear specific elements. ' +
|
||||
'Always call batch_get or snapshot_layout first to find the correct nodeId before deleting. ' +
|
||||
'Returns confirmation of the deleted node.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -38,9 +38,12 @@ interface OpResult {
|
|||
* M(nodeId, parent, index?) — Move
|
||||
* D(nodeId) — Delete
|
||||
*/
|
||||
export async function handleBatchDesign(
|
||||
params: BatchDesignParams,
|
||||
): Promise<{ results: OpResult[]; nodeCount: number; postProcessed?: boolean }> {
|
||||
export async function handleBatchDesign(params: BatchDesignParams): Promise<{
|
||||
results: OpResult[];
|
||||
nodeCount: number;
|
||||
postProcessed?: boolean;
|
||||
errors?: Array<{ line: string; error: string }>;
|
||||
}> {
|
||||
const filePath = resolveDocPath(params.filePath);
|
||||
let doc = await openDocument(filePath);
|
||||
doc = structuredClone(doc);
|
||||
|
|
@ -48,18 +51,20 @@ export async function handleBatchDesign(
|
|||
const pageId = params.pageId;
|
||||
const bindings = new Map<string, string>();
|
||||
const results: OpResult[] = [];
|
||||
const lines = params.operations
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('//'));
|
||||
const errors: Array<{ line: string; error: string }> = [];
|
||||
const lines = splitOperations(params.operations);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
await executeLine(line, doc, bindings, results, pageId);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Error executing "${line}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Best-effort: don't abort the entire batch on a single bad operation.
|
||||
// Collect errors so the agent can see what failed and retry selectively.
|
||||
const preview = line.length > 200 ? `${line.slice(0, 200)}...` : line;
|
||||
errors.push({
|
||||
line: preview,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,9 +110,51 @@ export async function handleBatchDesign(
|
|||
results,
|
||||
nodeCount: countNodes(getDocChildren(doc, pageId)),
|
||||
postProcessed: postProcessed || undefined,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split DSL operations handling multi-line JSON bodies. Splits on newlines
|
||||
* that are OUTSIDE quoted strings and balanced brackets. This lets agents
|
||||
* pretty-print JSON across multiple lines inside a single operation.
|
||||
*/
|
||||
function splitOperations(raw: string): string[] {
|
||||
const result: string[] = [];
|
||||
let buf = '';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const ch = raw[i];
|
||||
buf += ch;
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\' && inString) {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
||||
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
||||
else if (ch === '\n' && depth === 0) {
|
||||
const trimmed = buf.trim();
|
||||
if (trimmed && !trimmed.startsWith('//')) result.push(trimmed);
|
||||
buf = '';
|
||||
}
|
||||
}
|
||||
const tail = buf.trim();
|
||||
if (tail && !tail.startsWith('//')) result.push(tail);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function executeLine(
|
||||
line: string,
|
||||
doc: PenDocument,
|
||||
|
|
@ -116,11 +163,27 @@ async function executeLine(
|
|||
pageId?: string,
|
||||
): Promise<void> {
|
||||
// Parse: binding=OP(args) or OP(args)
|
||||
// Binding is optional for I/C/R/G — auto-generated when omitted (agents
|
||||
// sometimes write `I(parent, data)` without the binding prefix).
|
||||
const assignMatch = line.match(/^(\w+)\s*=\s*([ICRMG])\((.+)\)$/);
|
||||
const bindlessAssignMatch = !assignMatch && line.match(/^([ICRG])\((.+)\)$/);
|
||||
const callMatch = line.match(/^([UDM])\((.+)\)$/);
|
||||
|
||||
if (assignMatch) {
|
||||
const [, binding, op, argsStr] = assignMatch;
|
||||
// Normalize bindless form to an auto-generated binding so the rest of the
|
||||
// logic below can treat it uniformly.
|
||||
const effectiveAssign =
|
||||
assignMatch ??
|
||||
(bindlessAssignMatch
|
||||
? ([
|
||||
line,
|
||||
`_auto_${results.length}_${bindlessAssignMatch[1]}`,
|
||||
bindlessAssignMatch[1],
|
||||
bindlessAssignMatch[2],
|
||||
] as RegExpMatchArray)
|
||||
: null);
|
||||
|
||||
if (effectiveAssign) {
|
||||
const [, binding, op, argsStr] = effectiveAssign;
|
||||
switch (op) {
|
||||
case 'I': {
|
||||
const { parent, data } = parseInsertArgs(argsStr, bindings);
|
||||
|
|
@ -464,22 +527,105 @@ function applyDescendantOverrides(node: PenNode, descendants: Record<string, unk
|
|||
function parseJsonArg(str: string): Record<string, unknown> {
|
||||
const trimmed = str.trim();
|
||||
// Try strict JSON first (most common case — avoids mangling values like "Don't")
|
||||
let parsed: unknown;
|
||||
try {
|
||||
return sanitizeObject(JSON.parse(trimmed));
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
/* fall through to lenient parsing */
|
||||
}
|
||||
|
||||
let normalized = trimmed;
|
||||
// Convert JavaScript-style object to JSON: unquoted keys → quoted
|
||||
normalized = normalized.replace(/(?<=\{|,)\s*(\w+)\s*:/g, ' "$1":');
|
||||
// Replace single-quoted string delimiters with double quotes (not quotes inside strings)
|
||||
normalized = replaceSingleQuoteDelimiters(normalized);
|
||||
try {
|
||||
return sanitizeObject(JSON.parse(normalized));
|
||||
} catch {
|
||||
throw new Error(`Failed to parse JSON: ${str.slice(0, 200)}`);
|
||||
if (parsed === undefined) {
|
||||
let normalized = trimmed;
|
||||
// Convert JavaScript-style object to JSON: unquoted keys → quoted
|
||||
normalized = normalized.replace(/(?<=\{|,)\s*(\w+)\s*:/g, ' "$1":');
|
||||
// Replace single-quoted string delimiters with double quotes (not quotes inside strings)
|
||||
normalized = replaceSingleQuoteDelimiters(normalized);
|
||||
// Remove empty keys like `, ": 50,` (agent truncation artifact) —
|
||||
// skip the property entirely so the rest of the object still parses.
|
||||
normalized = normalized.replace(/,\s*""\s*:\s*[^,}\]]+/g, '');
|
||||
// Remove trailing commas before } or ] (common agent mistake)
|
||||
normalized = normalized.replace(/,(\s*[}\]])/g, '$1');
|
||||
try {
|
||||
parsed = JSON.parse(normalized);
|
||||
} catch (err) {
|
||||
const snippet = str.slice(0, 300);
|
||||
throw new Error(
|
||||
`Failed to parse JSON (${err instanceof Error ? err.message : 'unknown'}): ${snippet}${str.length > 300 ? '...' : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizeObject(normalizeNodeShape(parsed)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize common agent shorthand into canonical PenNode format.
|
||||
* Applied before sanitizeObject so downstream code sees a consistent shape.
|
||||
*/
|
||||
function normalizeNodeShape(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(normalizeNodeShape);
|
||||
if (!input || typeof input !== 'object') return input;
|
||||
const obj = input as Record<string, unknown>;
|
||||
|
||||
// fill: "#hex" | "solid#hex" → [{ type: 'solid', color: '#hex' }]
|
||||
// fill: { type, color } → [{ ... }]
|
||||
if ('fill' in obj) {
|
||||
obj.fill = normalizeFillField(obj.fill);
|
||||
}
|
||||
|
||||
// stroke: { thickness, color } | { thickness, color: '#hex' } →
|
||||
// { thickness, fill: [{ type: 'solid', color: '#hex' }] }
|
||||
// stroke: '#hex' → { thickness: 1, fill: [{ type: 'solid', color: '#hex' }] }
|
||||
if ('stroke' in obj) {
|
||||
obj.stroke = normalizeStrokeField(obj.stroke);
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
if (Array.isArray(obj.children)) {
|
||||
obj.children = obj.children.map((c) => normalizeNodeShape(c));
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function normalizeFillField(value: unknown): unknown {
|
||||
if (value == null) return value;
|
||||
// '#hex' shorthand
|
||||
if (typeof value === 'string') {
|
||||
return [{ type: 'solid', color: value }];
|
||||
}
|
||||
// Single fill object → wrap in array
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return [value];
|
||||
}
|
||||
// Already an array — leave as-is
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeStrokeField(value: unknown): unknown {
|
||||
if (value == null) return value;
|
||||
// '#hex' shorthand → stroke with solid fill
|
||||
if (typeof value === 'string') {
|
||||
return { thickness: 1, fill: [{ type: 'solid', color: value }] };
|
||||
}
|
||||
if (typeof value !== 'object') return value;
|
||||
const stroke = value as Record<string, unknown>;
|
||||
// { thickness, color: '#hex' } → { thickness, fill: [...] }
|
||||
if (stroke.color != null && stroke.fill == null) {
|
||||
stroke.fill = [{ type: 'solid', color: stroke.color }];
|
||||
delete stroke.color;
|
||||
}
|
||||
// { fill: '#hex' | object } → { fill: [...] }
|
||||
if (stroke.fill != null) {
|
||||
stroke.fill = normalizeFillField(stroke.fill);
|
||||
}
|
||||
// { thickness, type, color } variant
|
||||
if (stroke.type != null && stroke.color != null && stroke.fill == null) {
|
||||
stroke.fill = [{ type: stroke.type, color: stroke.color }];
|
||||
delete stroke.type;
|
||||
delete stroke.color;
|
||||
}
|
||||
return stroke;
|
||||
}
|
||||
|
||||
/** Replace single-quote string delimiters with double quotes, leaving apostrophes inside strings. */
|
||||
|
|
|
|||
|
|
@ -30,9 +30,18 @@ function getSkillContent(key: string): string {
|
|||
// Named prompt sections — can be retrieved individually via section parameter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INTRO = `You are generating designs for OpenPencil, a vector design tool.
|
||||
Use batch_design (for multi-node designs with DSL) or insert_node (for single node trees with JSON).
|
||||
Both support postProcess=true for automatic role defaults, icon resolution, and layout sanitization.
|
||||
const INTRO = `You are working with OpenPencil, a vector design tool.
|
||||
|
||||
TOOL SELECTION — match the user's intent:
|
||||
- READ/INSPECT the canvas: batch_get (search nodes, get IDs), snapshot_layout (spatial overview), get_selection (selected nodes)
|
||||
- CREATE new designs: batch_design (DSL, multi-node), insert_node (JSON, single tree) — both support postProcess=true
|
||||
- MODIFY existing nodes: update_node (change properties), replace_node (swap entirely)
|
||||
- DELETE/REMOVE elements: delete_node (remove by ID) — always batch_get first to find the correct ID
|
||||
- MOVE/COPY: move_node, copy_node
|
||||
|
||||
IMPORTANT: When the user asks to read, inspect, find, or look at existing content, use batch_get or snapshot_layout — do NOT create new nodes.
|
||||
When the user asks to delete or remove something, use batch_get to find it, then delete_node — do NOT create new nodes.
|
||||
|
||||
Each node must follow the PenNode schema below.`;
|
||||
|
||||
const DESIGN_TYPE_DETECTION = `DESIGN TYPE DETECTION:
|
||||
|
|
|
|||
|
|
@ -82,8 +82,12 @@ export async function handleOpenDocument(params: OpenDocumentParams): Promise<Op
|
|||
context: buildDocumentContext(doc),
|
||||
designPrompt: isEmpty
|
||||
? buildDesignPrompt()
|
||||
: 'Document has existing content. Use batch_design or insert_node with postProcess=true to add/modify designs. ' +
|
||||
'For complex multi-section designs, use the layered workflow: design_skeleton → design_content (per section) → design_refine. ' +
|
||||
: 'Document has existing content. Match your action to the user intent:\n' +
|
||||
'- READ/INSPECT: Use batch_get (search by type/name/ID) or snapshot_layout to see what is on the canvas.\n' +
|
||||
'- DELETE/REMOVE: Use batch_get to find the target node ID, then delete_node to remove it.\n' +
|
||||
'- MODIFY: Use update_node to change properties of existing nodes.\n' +
|
||||
'- ADD NEW: Use batch_design or insert_node with postProcess=true.\n' +
|
||||
'For complex multi-section designs, use the layered workflow: design_skeleton → design_content → design_refine. ' +
|
||||
'Call get_design_prompt(section="planning") for layered workflow guide, or get_design_prompt() for full guidelines.',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
73
packages/pen-react/CLAUDE.md
Normal file
73
packages/pen-react/CLAUDE.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# pen-react
|
||||
|
||||
React UI SDK for OpenPencil -- provider, hooks, panels, toolbar, and property editor components.
|
||||
|
||||
## Structure
|
||||
|
||||
### Core
|
||||
|
||||
- `src/context.ts` — `DesignEngineContext`: React context holding the `DesignEngine` instance
|
||||
- `src/provider.tsx` — `DesignProvider`: provides engine to React tree. Supports uncontrolled mode (`initialDocument`) and controlled mode (`document` + `onDocumentChange`) with echo-loop prevention
|
||||
|
||||
### Hooks (`src/hooks/`)
|
||||
|
||||
- `use-design-engine.ts` — `useDesignEngine()`: gets engine from context, throws if outside provider
|
||||
- `use-document.ts` — `useDocument()`: returns current `PenDocument`, re-renders on mutation
|
||||
- `use-selection.ts` — `useSelection()`: returns `string[]` of selected node IDs
|
||||
- `use-viewport.ts` — `useViewport()`: returns `ViewportState` (zoom, panX, panY)
|
||||
- `use-active-tool.ts` — `useActiveTool()`: returns current `ToolType`
|
||||
- `use-history.ts` — `useHistory()`: returns `{ canUndo, canRedo }`
|
||||
- `use-active-node.ts` — `useActiveNode()`: returns the single active `PenNode` or null
|
||||
- `use-active-page.ts` — `useActivePage()`: returns active page ID
|
||||
- `use-hover.ts` — `useHover()`: returns hovered node ID
|
||||
- `use-variables.ts` — `useVariables()`: returns document variables map
|
||||
|
||||
### Utilities (`src/utils/`)
|
||||
|
||||
- `use-engine-subscribe.ts` — `useEngineSubscribe(engine, event, getSnapshot)`: generic hook using `useSyncExternalStore` to subscribe to engine events with stable snapshot refs
|
||||
|
||||
### Stores (`src/stores/`)
|
||||
|
||||
- `ui-store.ts` — `useUIStore` (Zustand): pure UI state -- panel visibility, layer drag state, collapsed nodes. NOT engine state
|
||||
|
||||
### Components (`src/components/`)
|
||||
|
||||
- `design-canvas.tsx` — `DesignCanvas`: canvas element with CanvasKit rendering via `attachCanvas` + `attachInteraction` from pen-engine browser adapter
|
||||
- `core-toolbar.tsx` — `CoreToolbar`: main tool selection bar
|
||||
- `tool-button.tsx` — `ToolButton`: individual tool button with active state
|
||||
- `shape-tool-dropdown.tsx` — `ShapeToolDropdown`: dropdown for shape tools (rectangle, ellipse, line, polygon)
|
||||
- `layer-panel.tsx` — `LayerPanel`: document tree panel with drag-and-drop reordering
|
||||
- `layer-item.tsx` — `LayerItem`: individual layer row with visibility/lock toggles
|
||||
- `layer-context-menu.tsx` — `LayerContextMenu`: right-click context menu for layers
|
||||
- `property-panel.tsx` — `PropertyPanel`: right-side property inspector (delegates to section components)
|
||||
- `color-picker.tsx` — `ColorPicker`: color input with hex/opacity, gradient support
|
||||
- `number-input.tsx` — `NumberInput`: numeric input with drag-to-adjust
|
||||
- `section-header.tsx` — `SectionHeader`: collapsible section header
|
||||
- `font-picker.tsx` — `FontPicker`: font family selector with search
|
||||
- `variable-picker.tsx` — `VariablePicker`: design variable selector
|
||||
- `icon-picker-dialog.tsx` — `IconPickerDialog`: icon search and selection
|
||||
- `boolean-toolbar.tsx` — `BooleanToolbar`: union/subtract/intersect boolean operations
|
||||
- `page-tabs.tsx` — `PageTabs`: multi-page tab bar
|
||||
- `status-bar.tsx` — `StatusBar`: bottom status bar (zoom, selection info)
|
||||
|
||||
### Property sections (`src/components/sections/`)
|
||||
|
||||
- `size-section.tsx` — Width, height, x, y, rotation
|
||||
- `fill-section.tsx` — Fill array editor (solid, gradient, image)
|
||||
- `stroke-section.tsx` — Stroke properties (thickness, alignment, dash)
|
||||
- `text-section.tsx` — Text content, font, size, weight, style
|
||||
- `text-layout-section.tsx` — Text alignment, growth mode
|
||||
- `corner-radius-section.tsx` — Corner radius (uniform or per-corner)
|
||||
- `effects-section.tsx` — Blur and shadow effects
|
||||
- `layout-section.tsx` — Auto-layout direction, gap, alignment
|
||||
- `layout-padding-section.tsx` — Padding (uniform or per-side)
|
||||
- `appearance-section.tsx` — Opacity, blend mode, visibility
|
||||
- `icon-section.tsx` — Icon font name and family
|
||||
- `image-section.tsx` — Image source, fit mode, adjustments
|
||||
- `export-section.tsx` — Export format and code preview
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun --bun vitest run packages/pen-react/src/__tests__/
|
||||
```
|
||||
21
packages/pen-react/LICENSE
Normal file
21
packages/pen-react/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
196
packages/pen-react/README.md
Normal file
196
packages/pen-react/README.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# @zseven-w/pen-react
|
||||
|
||||
React UI SDK for [OpenPencil](https://github.com/ZSeven-W/openpencil) — a complete set of hooks, components, and panels to build a design editor with React.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @zseven-w/pen-react
|
||||
# or
|
||||
bun add @zseven-w/pen-react
|
||||
```
|
||||
|
||||
**Peer dependencies:** `react@^19`, `react-dom@^19`, `@radix-ui/react-*` (popover, select, separator, slider, switch, toggle, tooltip)
|
||||
|
||||
## Overview
|
||||
|
||||
`pen-react` wraps [`@zseven-w/pen-engine`](../pen-engine) into idiomatic React: a context provider, 10 semantic hooks, and 39 ready-to-use components covering the full editor UI.
|
||||
|
||||
```
|
||||
<DesignProvider>
|
||||
<CoreToolbar />
|
||||
<DesignCanvas />
|
||||
<LayerPanel />
|
||||
<PropertyPanel />
|
||||
<PageTabs />
|
||||
<StatusBar />
|
||||
</DesignProvider>
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import {
|
||||
DesignProvider,
|
||||
DesignCanvas,
|
||||
CoreToolbar,
|
||||
LayerPanel,
|
||||
PropertyPanel,
|
||||
} from '@zseven-w/pen-react';
|
||||
|
||||
function Editor() {
|
||||
return (
|
||||
<DesignProvider initialDocument={myDoc}>
|
||||
<div className="flex h-screen">
|
||||
<LayerPanel />
|
||||
<div className="flex flex-col flex-1">
|
||||
<CoreToolbar />
|
||||
<DesignCanvas className="flex-1" />
|
||||
</div>
|
||||
<PropertyPanel />
|
||||
</div>
|
||||
</DesignProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
All hooks subscribe to the engine and re-render on change:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useDesignEngine,
|
||||
useDocument,
|
||||
useSelection,
|
||||
useViewport,
|
||||
useActiveTool,
|
||||
useHistory,
|
||||
useActiveNode,
|
||||
useActivePage,
|
||||
useHover,
|
||||
useVariables,
|
||||
} from '@zseven-w/pen-react';
|
||||
|
||||
function Inspector() {
|
||||
const node = useActiveNode(); // PenNode | null
|
||||
const selection = useSelection(); // string[]
|
||||
const { canUndo, undo } = useHistory();
|
||||
const viewport = useViewport(); // { zoom, panX, panY }
|
||||
const tool = useActiveTool(); // ToolType
|
||||
const doc = useDocument(); // PenDocument
|
||||
const page = useActivePage(); // PenPage
|
||||
const hoverId = useHover(); // string | null
|
||||
const variables = useVariables(); // VariableDefinition[]
|
||||
const engine = useDesignEngine(); // DesignEngine (escape hatch)
|
||||
|
||||
return <div>Selected: {selection.length} nodes</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Provider
|
||||
|
||||
### Uncontrolled mode
|
||||
|
||||
Engine owns the document. Good for standalone editors:
|
||||
|
||||
```tsx
|
||||
<DesignProvider initialDocument={doc}>{children}</DesignProvider>
|
||||
```
|
||||
|
||||
### Controlled mode
|
||||
|
||||
Parent owns the document. Good for integration into existing state:
|
||||
|
||||
```tsx
|
||||
<DesignProvider document={doc} onDocumentChange={(newDoc) => setDoc(newDoc)}>
|
||||
{children}
|
||||
</DesignProvider>
|
||||
```
|
||||
|
||||
Echo-loop prevention is built in — `onDocumentChange` won't fire for changes that originated from the parent.
|
||||
|
||||
## Components
|
||||
|
||||
### Canvas
|
||||
|
||||
| Component | Description |
|
||||
| -------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `DesignCanvas` | GPU-rendered canvas with CanvasKit/Skia. Handles zoom, pan, resize, and all interactions. |
|
||||
|
||||
```tsx
|
||||
<DesignCanvas
|
||||
className="w-full h-full"
|
||||
onReady={(engine) => console.log('Canvas ready')}
|
||||
loadingFallback={<Spinner />}
|
||||
/>
|
||||
```
|
||||
|
||||
### Toolbar
|
||||
|
||||
| Component | Description |
|
||||
| ------------------- | ----------------------------------------------------------------- |
|
||||
| `CoreToolbar` | Main tool selection bar (select, frame, shapes, text, pen, image) |
|
||||
| `ToolButton` | Individual tool button with icon + active state |
|
||||
| `ShapeToolDropdown` | Dropdown for shape tools (rectangle, ellipse, polygon, line) |
|
||||
| `BooleanToolbar` | Union, subtract, intersect, exclude operations |
|
||||
|
||||
### Panels
|
||||
|
||||
| Component | Description |
|
||||
| ------------------ | ----------------------------------------------------- |
|
||||
| `LayerPanel` | Hierarchical tree view with drag-and-drop reordering |
|
||||
| `LayerItem` | Single layer row — collapse, visibility, lock, rename |
|
||||
| `LayerContextMenu` | Right-click menu: copy, paste, delete, group, z-order |
|
||||
| `PropertyPanel` | Tabbed property inspector for the selected node |
|
||||
| `PageTabs` | Multi-page tab bar with add/rename/reorder/delete |
|
||||
| `StatusBar` | Bottom bar with zoom, coordinates, node count |
|
||||
|
||||
### Property Sections
|
||||
|
||||
Drop these into your own property panel or use `PropertyPanel` which includes all of them:
|
||||
|
||||
| Section | Edits |
|
||||
| ---------------------- | ----------------------------------------------- |
|
||||
| `SizeSection` | x, y, width, height, rotation, constraints |
|
||||
| `FillSection` | Solid color, linear/radial gradient |
|
||||
| `StrokeSection` | Color, thickness, dash pattern, cap, join |
|
||||
| `TextSection` | Font family, size, weight, color, alignment |
|
||||
| `TextLayoutSection` | Line height, letter spacing, paragraph spacing |
|
||||
| `CornerRadiusSection` | Uniform or per-corner border radius |
|
||||
| `EffectsSection` | Drop shadow, inner shadow, blur |
|
||||
| `LayoutSection` | Auto-layout direction, gap, justify, align |
|
||||
| `LayoutPaddingSection` | Uniform or per-side padding |
|
||||
| `AppearanceSection` | Opacity, blend mode |
|
||||
| `IconSection` | Icon name picker (Lucide icons) |
|
||||
| `ImageSection` | Image source, fit mode |
|
||||
| `ExportSection` | Code generation target (React, HTML, Vue, etc.) |
|
||||
|
||||
### Shared UI
|
||||
|
||||
| Component | Description |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| `ColorPicker` | Color input with swatch palette and hex input |
|
||||
| `NumberInput` | Numeric field with drag-to-adjust and arrow keys |
|
||||
| `SectionHeader` | Collapsible section header with title + actions |
|
||||
| `FontPicker` | Font family selector with preview |
|
||||
| `VariablePicker` | Design variable reference picker (`$primary`, etc.) |
|
||||
| `IconPickerDialog` | Modal icon browser with search and categories |
|
||||
|
||||
## UI Store
|
||||
|
||||
Ephemeral UI state (panel open/close, drag state) managed by Zustand — separate from engine state:
|
||||
|
||||
```tsx
|
||||
import { useUIStore } from '@zseven-w/pen-react';
|
||||
|
||||
const { layerPanelOpen, toggleLayerPanel } = useUIStore();
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Components use [Tailwind CSS](https://tailwindcss.com/) + [CVA](https://cva.style/docs) for variant styling, and [Radix UI](https://www.radix-ui.com/) primitives for accessibility. Override styles via `className` props or Tailwind's design token system.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
|
@ -1,7 +1,21 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-react",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "React UI SDK for OpenPencil — hooks, components, and state bridges for pen-engine",
|
||||
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-react",
|
||||
"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": "packages/pen-react"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
|
|
|
|||
31
packages/pen-renderer/CLAUDE.md
Normal file
31
packages/pen-renderer/CLAUDE.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# pen-renderer
|
||||
|
||||
Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files. No React, no Zustand -- pure TypeScript + CanvasKit WASM.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/renderer.ts` — `PenRenderer`: high-level read-only renderer. Methods: `init(canvas)`, `setDocument(doc)`, `render()`, `zoomToFit()`, `zoomTo(zoom, x, y)`, `pan(dx, dy)`, `hitTest(x, y)`, `dispose()`. Manages viewport, render nodes, spatial index, and frame labels
|
||||
- `src/document-flattener.ts` — Tree flattening with layout resolution: `flattenToRenderNodes` (recursive walk producing `RenderNode[]` with absolute positions), `resolveRefs` (ref node resolution), `remapIds`, `premeasureTextHeights` (Canvas 2D text measurement for accurate heights), `collectReusableIds`, `collectInstanceIds`
|
||||
- `src/node-renderer.ts` — `SkiaNodeRenderer`: core draw calls for all node types (rectangles, ellipses, paths, text, images, icons, lines, polygons). Handles fills (solid, gradient, image), strokes, effects (shadow, blur), corner radius, clip content, opacity, blend mode
|
||||
- `src/text-renderer.ts` — `SkiaTextRenderer`: text rendering sub-system with both vector (Paragraph API) and bitmap (Canvas 2D rasterization) paths. FIFO caches with byte-based eviction limits (256 MB text, 64 MB paragraph)
|
||||
- `src/paint-utils.ts` — Color/paint utilities: `parseColor` (hex to CanvasKit Color4f), `cornerRadiusValue`, `cornerRadii`, `resolveFillColor`, `resolveStrokeColor`, `resolveStrokeWidth`, `wrapLine`, `cssFontFamily`
|
||||
- `src/path-utils.ts` — SVG path utilities: `sanitizeSvgPath` (normalize for CanvasKit parser), `hasInvalidNumbers`, `tryManualPathParse`
|
||||
- `src/image-loader.ts` — `SkiaImageLoader`: async image loading with browser Image element, Canvas 2D rasterization, CanvasKit Image conversion, and caching. Supports custom source resolvers
|
||||
- `src/font-manager.ts` — `SkiaFontManager`: font management with bundled fonts (Inter, Poppins, Roboto, etc.) and Google Fonts CSS fetching. `BUNDLED_FONT_FAMILIES` constant
|
||||
- `src/spatial-index.ts` — `SpatialIndex`: R-tree backed (rbush) spatial queries for hit testing. Methods: `rebuild`, `hitTest`, `searchRect`, `get`. Returns results topmost-first by render order
|
||||
- `src/viewport.ts` — Viewport math: `viewportMatrix` (3x3 transform for CanvasKit), `screenToScene`, `sceneToScreen`, `zoomToPoint`, `getViewportBounds`, `isRectInViewport`
|
||||
- `src/init.ts` — `loadCanvasKit(options)`, `getCanvasKit()`: singleton CanvasKit WASM loader
|
||||
- `src/render-node-thumbnail.ts` — `renderNodeThumbnail(node, context)`: offscreen thumbnail helper for individual nodes (used by git conflict UI). Returns data URL or null
|
||||
- `src/types.ts` — `RenderNode` (node + absolute bounds + clip rect), `PenRendererOptions`, `IconLookupFn`
|
||||
|
||||
## Key exports
|
||||
|
||||
Primary: `loadCanvasKit`, `getCanvasKit`, `PenRenderer`
|
||||
|
||||
Low-level (for editor re-use): `SkiaNodeRenderer`, `SkiaTextRenderer`, `SkiaFontManager`, `SkiaImageLoader`, `SpatialIndex`, `flattenToRenderNodes`, `resolveRefs`, `premeasureTextHeights`, `viewportMatrix`, `screenToScene`, `sceneToScreen`, `zoomToPoint`, `parseColor`, `sanitizeSvgPath`, `renderNodeThumbnail`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun --bun vitest run packages/pen-renderer/src/__tests__/
|
||||
```
|
||||
21
packages/pen-renderer/LICENSE
Normal file
21
packages/pen-renderer/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,64 +1,190 @@
|
|||
# @zseven-w/pen-renderer
|
||||
|
||||
Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/nicepkg/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
|
||||
Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/ZSeven-W/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @zseven-w/pen-renderer canvaskit-wasm
|
||||
# or
|
||||
bun add @zseven-w/pen-renderer canvaskit-wasm
|
||||
```
|
||||
|
||||
`canvaskit-wasm` is a peer dependency — you provide the WASM binary.
|
||||
|
||||
## Usage
|
||||
## Overview
|
||||
|
||||
```ts
|
||||
`pen-renderer` is a pure TypeScript + CanvasKit rendering pipeline with no React or framework dependency. It takes a `PenDocument` and renders it to a WebGL surface with GPU acceleration. The pipeline:
|
||||
|
||||
```
|
||||
PenDocument → flattenToRenderNodes() → absolute positions → SkiaNodeRenderer → GPU canvas
|
||||
↓
|
||||
SpatialIndex (R-tree) → hitTest / searchRect
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer';
|
||||
|
||||
// Initialize CanvasKit
|
||||
// 1. Initialize CanvasKit WASM (once, globally)
|
||||
await loadCanvasKit();
|
||||
|
||||
// Create renderer on a canvas element
|
||||
// 2. Create renderer on a canvas element
|
||||
const renderer = new PenRenderer(canvas, document, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
|
||||
// Render
|
||||
// 3. Render
|
||||
renderer.render();
|
||||
|
||||
// 4. Interact
|
||||
renderer.zoomToFit();
|
||||
renderer.zoomTo(1.5, centerX, centerY);
|
||||
renderer.pan(deltaX, deltaY);
|
||||
const node = renderer.hitTest(mouseX, mouseY);
|
||||
|
||||
// 5. Cleanup
|
||||
renderer.dispose();
|
||||
```
|
||||
|
||||
## API
|
||||
## Features
|
||||
|
||||
### High-level
|
||||
### High-Level Renderer
|
||||
|
||||
- **`loadCanvasKit()`** — Initialize the CanvasKit WASM module
|
||||
- **`PenRenderer`** — Full-featured renderer with viewport, selection, and interaction support
|
||||
`PenRenderer` provides a complete rendering solution with viewport, selection, and interaction:
|
||||
|
||||
```typescript
|
||||
const renderer = new PenRenderer(canvas, document, options);
|
||||
|
||||
renderer.setDocument(newDoc); // Update document
|
||||
renderer.render(); // Trigger re-render
|
||||
renderer.zoomToFit(); // Fit content to viewport
|
||||
renderer.zoomTo(zoom, cx, cy); // Zoom to point
|
||||
renderer.pan(dx, dy); // Pan viewport
|
||||
renderer.hitTest(x, y); // Hit test at screen coords
|
||||
renderer.dispose(); // Free resources
|
||||
```
|
||||
|
||||
### Document Flattening
|
||||
|
||||
Pre-process documents for rendering:
|
||||
Pre-process the document tree into flat render nodes with absolute positions:
|
||||
|
||||
```ts
|
||||
import { flattenToRenderNodes, resolveRefs, premeasureTextHeights } from '@zseven-w/pen-renderer';
|
||||
```typescript
|
||||
import {
|
||||
flattenToRenderNodes,
|
||||
resolveRefs,
|
||||
premeasureTextHeights,
|
||||
remapIds,
|
||||
} from '@zseven-w/pen-renderer';
|
||||
|
||||
// Flatten tree → absolute positions
|
||||
const renderNodes = flattenToRenderNodes(children, viewport);
|
||||
|
||||
// Resolve $ref nodes to their source
|
||||
const resolved = resolveRefs(renderNodes, document);
|
||||
|
||||
// Pre-measure text heights using Canvas 2D (for accurate layout)
|
||||
premeasureTextHeights(renderNodes, canvasContext);
|
||||
```
|
||||
|
||||
### Viewport Utilities
|
||||
### Viewport Math
|
||||
|
||||
```ts
|
||||
import { viewportMatrix, screenToScene, sceneToScreen, zoomToPoint } from '@zseven-w/pen-renderer';
|
||||
Camera transforms for pan, zoom, and coordinate conversion:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
viewportMatrix,
|
||||
screenToScene,
|
||||
sceneToScreen,
|
||||
zoomToPoint,
|
||||
getViewportBounds,
|
||||
isRectInViewport,
|
||||
} from '@zseven-w/pen-renderer';
|
||||
|
||||
const matrix = viewportMatrix(zoom, panX, panY); // 3x3 CanvasKit matrix
|
||||
const scene = screenToScene(mouseX, mouseY, viewport);
|
||||
const screen = sceneToScreen(nodeX, nodeY, viewport);
|
||||
const newVp = zoomToPoint(viewport, 2.0, centerX, centerY);
|
||||
```
|
||||
|
||||
### Low-level Renderers
|
||||
### Spatial Index
|
||||
|
||||
R-tree backed spatial queries for click hit testing and marquee selection:
|
||||
|
||||
```typescript
|
||||
import { SpatialIndex } from '@zseven-w/pen-renderer';
|
||||
|
||||
const index = new SpatialIndex();
|
||||
index.rebuild(renderNodes);
|
||||
|
||||
const clicked = index.hitTest(x, y); // topmost node at point
|
||||
const selected = index.searchRect(x, y, w, h); // all nodes in rect
|
||||
const node = index.get(nodeId); // lookup by ID
|
||||
```
|
||||
|
||||
### Low-Level Renderers
|
||||
|
||||
For custom rendering pipelines:
|
||||
|
||||
- `SkiaNodeRenderer` — Renders individual nodes to a Skia canvas
|
||||
- `SkiaTextRenderer` — Text layout and rendering
|
||||
- `SkiaFontManager` — Font loading and management
|
||||
- `SkiaImageLoader` — Async image loading with caching
|
||||
- `SpatialIndex` — R-tree spatial index for hit testing
|
||||
```typescript
|
||||
import {
|
||||
SkiaNodeRenderer,
|
||||
SkiaTextRenderer,
|
||||
SkiaFontManager,
|
||||
SkiaImageLoader,
|
||||
} from '@zseven-w/pen-renderer';
|
||||
```
|
||||
|
||||
| Class | Handles |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SkiaNodeRenderer` | All node types — rectangles, ellipses, paths, images, icons, lines, polygons. Fills (solid, gradient, image), strokes, effects (shadow, blur), corner radius, clip, opacity, blend mode |
|
||||
| `SkiaTextRenderer` | Text layout and rendering via Paragraph API with bitmap fallback. FIFO caches (256 MB text, 64 MB paragraph) |
|
||||
| `SkiaFontManager` | Font loading — bundled fonts (Inter, Poppins, Roboto, etc.) + Google Fonts CSS fetching |
|
||||
| `SkiaImageLoader` | Async image loading with caching and custom source resolvers |
|
||||
|
||||
### Thumbnail Generation
|
||||
|
||||
Render individual nodes to offscreen thumbnails (used for git conflict UI, exports):
|
||||
|
||||
```typescript
|
||||
import { renderNodeThumbnail } from '@zseven-w/pen-renderer';
|
||||
|
||||
const dataUrl = renderNodeThumbnail(node, { width: 200, height: 200 });
|
||||
```
|
||||
|
||||
### Paint Utilities
|
||||
|
||||
```typescript
|
||||
import {
|
||||
parseColor,
|
||||
resolveFillColor,
|
||||
resolveStrokeColor,
|
||||
wrapLine,
|
||||
cssFontFamily,
|
||||
sanitizeSvgPath,
|
||||
} from '@zseven-w/pen-renderer';
|
||||
|
||||
const color = parseColor('#2563EB'); // CanvasKit Color4f
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Category | Exports |
|
||||
| ------------- | -------------------------------------------------------------------------------------- |
|
||||
| **Init** | `loadCanvasKit(options?)`, `getCanvasKit()` |
|
||||
| **Renderer** | `PenRenderer` |
|
||||
| **Flatten** | `flattenToRenderNodes`, `resolveRefs`, `premeasureTextHeights`, `remapIds` |
|
||||
| **Viewport** | `viewportMatrix`, `screenToScene`, `sceneToScreen`, `zoomToPoint`, `getViewportBounds` |
|
||||
| **Spatial** | `SpatialIndex` — `rebuild`, `hitTest`, `searchRect`, `get` |
|
||||
| **Node** | `SkiaNodeRenderer` |
|
||||
| **Text** | `SkiaTextRenderer` |
|
||||
| **Font** | `SkiaFontManager`, `BUNDLED_FONT_FAMILIES` |
|
||||
| **Image** | `SkiaImageLoader` |
|
||||
| **Paint** | `parseColor`, `sanitizeSvgPath`, `cssFontFamily` |
|
||||
| **Thumbnail** | `renderNodeThumbnail` |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](./LICENSE)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
{
|
||||
"name": "@zseven-w/pen-renderer",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
|
||||
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-renderer",
|
||||
"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": "packages/pen-renderer"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
|
|
|
|||
29
packages/pen-sdk/CLAUDE.md
Normal file
29
packages/pen-sdk/CLAUDE.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# pen-sdk
|
||||
|
||||
Umbrella SDK that re-exports all OpenPencil packages from a single entry point.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/index.ts` — Single barrel file re-exporting from:
|
||||
- `@zseven-w/pen-types` — All document model types and codegen types
|
||||
- `@zseven-w/pen-core` — Tree operations, layout engine, variables, normalization, boolean ops
|
||||
- `@zseven-w/pen-engine` — `DesignEngine` and all managers
|
||||
- `@zseven-w/pen-react` — All hooks, components, and stores (`export *`)
|
||||
- `@zseven-w/pen-renderer` — `PenRenderer`, CanvasKit loader, low-level rendering utilities
|
||||
- `@zseven-w/pen-figma` — Figma file parser and converter
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import {
|
||||
type PenDocument,
|
||||
createEmptyDocument,
|
||||
DesignEngine,
|
||||
DesignProvider,
|
||||
useDocument,
|
||||
PenRenderer,
|
||||
parseFigFile,
|
||||
} from '@zseven-w/pen-sdk';
|
||||
```
|
||||
|
||||
Consumers can import from `@zseven-w/pen-sdk` instead of individual packages. All types, runtime exports, and React hooks are available.
|
||||
21
packages/pen-sdk/LICENSE
Normal file
21
packages/pen-sdk/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ZSeven—W
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue