* fix(renderer): skip paragraph image cache when zoomed in

* refactor(publish-cli): streamline version publishing process

- Removed the check for already published versions to simplify the workflow.
- Updated npm publish commands to handle already published versions gracefully, allowing the process to continue without aborting.
- Added publishing step for the new pen-ai-skills package, ensuring it is included in the deployment process.

* fix(mcp): wire openpencil codex install to config toml (#85)

Co-authored-by: Kaiiiiiiiii <182183652+Smile232323@users.noreply.github.com>

* refactor(mcp): reorganize codex-cli handling in installation process

- Removed the codex-cli configuration from CLI_CONFIGS and adjusted the installation logic to handle codex-cli actions directly.
- Improved error handling for unknown CLI tools and streamlined the installation process for codex-cli, ensuring it uses its own commands for adding/removing components.

* feat(agent): add built-in agent SDK and web integration

Introduces @zseven-w/agent — a domain-agnostic agent SDK with:
- Agent loop (plan→act→observe) with dual execution model
- Anthropic + OpenAI-compatible provider adapters via Vercel AI SDK
- Tool registry with auth levels (read/create/modify/delete/orchestrate)
- Agent team orchestration with delegate tool
- SSE streaming protocol encoder/decoder
- Sliding window context management

Web app integration:
- Built-in provider config UI with presets (Anthropic, OpenAI, OpenRouter, DeepSeek)
- Model search via provider API proxy endpoint
- Built-in models appear in the model dropdown alongside CLI agents
- Server-side agent SSE endpoint with session management
- Client-side tool executor with undo batch support
- Inline tool call blocks in chat UI

* feat(api): implement keep-alive pings for long-running LLM calls

- Added a keep-alive mechanism in the SSE stream for the agent API to prevent timeouts during long-running LLM calls.
- Updated the keep-alive interval to 8 seconds to align with Bun.serve's default idle timeout of 10 seconds.
- Ensured proper cleanup of the ping timer when the stream is closed.

* feat(mcp): enhance live sync diagnostics and port file management

- Implemented a mechanism to probe the live sync server's availability, providing clearer diagnostics for connection issues.
- Updated the port file structure to include a unique token for better management and cleanup.
- Enhanced the `getSyncUrl` and `getLiveSyncState` functions to return detailed status messages based on the server's response.
- Added tests for live sync diagnostics to ensure accurate reporting of server states.

* feat(mcp): implement sync URL caching for improved performance

- Added caching mechanism for sync URLs to reduce repeated reads and health checks, enhancing performance during live document and selection fetches.
- Introduced functions to set and clear the cached sync URL, improving connection management.
- Updated existing functions to utilize the cached URL when available, streamlining the fetching process.

* feat(skia): enhance syncFromDocument error handling and normalize document structure

- Wrapped the syncFromDocument method in a try-catch block to log errors during synchronization, improving debugging capabilities.
- Introduced normalization of external documents in the document store to ensure consistent structure before processing.
- Updated text content resolution to handle both 'content' and 'text' fields, enhancing compatibility with various node formats.
- Refactored text measurement functions to streamline text handling and improve layout consistency.

* chore: update package versions to 0.6.0 across all modules and enhance code generation pipeline

- Bumped version from 0.5.3 to 0.6.0 in package.json files for all modules including core, agent, and various code generation packages.
- Refactored code generation functions to utilize a new AI code generation pipeline, marking previous functions as deprecated with plans for removal in v1.0.0.
- Introduced new export nodes functionality in the MCP server for raw PenNode data export.

* fix(mcp): clear stale sync cache on page load to prevent false dirty state

When refreshing the page or opening a new file, the SSE document:init
event would echo back the old cached document, causing applyExternalDocument
to unconditionally set isDirty=true and trigger a spurious save prompt.

* style(panels): improve AI checklist visual design

Add progress bar, spinner animation, highlighted active items, and
pill-shaped counter badge for a more polished orchestrator progress UI.

* feat(ci): add Homebrew Cask update workflow for OpenPencil releases

- Implemented a new GitHub Actions job to update the Homebrew Cask for OpenPencil upon new version tags.
- The workflow fetches the latest macOS binaries, computes their SHA256 checksums, and updates the cask formula accordingly.
- Includes steps for committing and pushing changes to the tap repository.

* docs: update installation instructions in README and enhance CI workflow for Scoop bucket updates

- Added detailed installation instructions for macOS, Windows, and Linux in the README.
- Introduced a new GitHub Actions job to update the Scoop bucket with the latest OpenPencil release, including versioning and SHA256 checksum computation for Windows binaries.

* docs: add OpenPencil Skill plugin link to all i18n READMEs

Add localized LLM Skill link in CLI section across all 15 README
files, pointing to zseven-w/openpencil-skill repository.

* docs: add same-name project disclaimer to all i18n READMEs

Distinguish from open-pencil/open-pencil (Figma-compatible visual
design with real-time collaboration) — this project focuses on
AI-native design-to-code workflows.

* fix(agent): resolve SSE timeout, routing, and message format issues

- Add 5s keep-alive ping to agent SSE stream (prevents Bun 10s idle timeout)
- Consolidate tool-result and abort into single agent endpoint (?action=result|abort)
- Fix assistant message history: include text alongside tool-call parts
- Fix ToolResultPart format: use 'output' field with {type:'text',value} structure
- Use openai.chat() instead of openai() for Chat Completions API (OpenRouter compat)
- Use proper TOOL_SCHEMAS instead of z.any() for tool parameters

* fix(agent): prevent sliding window from splitting assistant+tool groups

The sliding window context strategy was naively slicing by message count,
which could leave orphaned tool_result messages without their preceding
assistant tool_use — causing Claude API to reject the request.

Now counts "logical turns" (user/assistant starts) and never cuts inside
an assistant→tool group. Also increased default window from 10 to 50 turns.

* fix(agent): use correct AI SDK v6 parameter name maxOutputTokens

streamText() in AI SDK v6 renamed maxTokens to maxOutputTokens.
Default 4096 prevents OpenRouter credit limit errors from requesting
the model's full 65536 token capacity.

* feat(agent): add API error classification and auto-retry without tools

When a model doesn't support function calling (400 errors, missing
arguments), the agent loop now classifies the error and automatically
retries without tools on the first turn. Also provides user-friendly
error messages for common failures: privacy restrictions, credit limits,
rate limiting, and incompatible models.

* fix(agent): improve system prompt with PenNode schema and workflow guidance

- Add PenNode property schema to agent system prompt so models know
  valid properties (fills, cornerRadius, padding array, etc.)
- Explicitly forbid CSS properties (backgroundColor, boxShadow, etc.)
- Add workflow steps: plan before creating, avoid create-then-delete
- Change max turns reached from error to normal done event

* feat(agent): align insert_node with CLI batch_design capability

- insert_node now supports nested children arrays (recursive ID generation)
- Post-processing pipeline runs after insertion: role resolution, icon
  resolution, layout sanitization, unique ID enforcement — same as MCP
  batch_design with postProcess=true
- System prompt teaches model to create entire designs in ONE insert_node
  call with nested children, instead of 20+ individual calls
- Includes concrete example of nested PenNode structure in prompt

* fix(agent): deep-clone node before post-processing to avoid Zustand mutation

Zustand state is immutable — post-processing functions mutate in-place.
JSON.parse(JSON.stringify()) creates a mutable copy. Each post-processing
step is individually try-caught so partial failures don't break insertion.

* fix(agent): return node count in insert_node result to prevent model retries

insert_node now returns {id, nodesCreated, message} so the model knows
the full tree was created. System prompt reinforces: when insert_node
succeeds, do NOT retry. Reduces 5+ duplicate insert calls to exactly 1.

* fix(agent): prevent duplicate root-level inserts from weaker models

Track the first root-level insert_node ID per session. Subsequent
root-level insert_node calls return the existing ID with a message
instead of creating duplicates. Fixes M2.5 and similar models that
ignore "do not retry" prompt instructions.

* feat(agent): support Anthropic API format in custom provider settings

Custom provider preset now has an API Format toggle: "OpenAI Compatible"
or "Anthropic". This allows users to configure custom Anthropic-compatible
endpoints (proxies, mirrors) in addition to OpenAI-compatible ones.
Anthropic provider also now accepts optional baseURL for proxy support.

* fix(agent): update Custom option label in provider dropdown

* fix(agent): match 'invalid function arguments' in error classifier for auto-retry

* fix(agent): always auto-retry without tools on first turn errors

Many providers (MiniMax, StepFun) have broken tool call implementations.
Instead of pattern-matching specific error messages, any streaming error
on turn 0 now triggers auto-retry without tools. The model falls back
to generating a text-only response instead of failing entirely.

* fix(agent): use clean JSON Schema for tool definitions (no $schema field)

Replaced Zod schemas with jsonSchema() from AI SDK for tool parameter
definitions. Zod's zodToJsonSchema adds "$schema" and "additionalProperties"
fields that strict OpenAI-compatible APIs (MiniMax, StepFun) reject with
"invalid function arguments". Clean JSON Schema only has type/properties/
required — compatible with all providers.

Also added text-mode fallback prompt for when tools are truly unsupported.

* fix(agent): ensure tool call args is always {} and fix turn>0 retry

Two fixes for MiniMax "invalid function arguments" error:
1. Ensure args is always {} (never undefined/null) when building
   conversation history — undefined args causes strict APIs to reject
2. Auto-retry without tools now works on ANY turn (not just turn 0)
   by resetting history to original user messages and restarting

* fix(agent): use SDK response.messages for history instead of manual construction

Root cause of MiniMax "invalid function arguments" error: we were manually
constructing ModelMessage[] for conversation history, which the SDK then
failed to convert correctly to OpenAI format (dropping function.arguments).

Fix: use response.response.messages from the SDK — it produces correctly
formatted messages that the provider knows how to convert. Only manually
add tool result messages for client-executed tools (no execute() function).

Also keeps a fetch interceptor as safety net to patch any remaining
edge cases with missing/malformed arguments.

* fix(agent): parse stringified JSON in insert_node data parameter

Some models (MiniMax M2.5) send insert_node data as a JSON string
instead of an object. Now handleInsertNode detects string data and
JSON.parse it before processing. Fixes nodesCreated:1 when model
sends nested children as a stringified object.

* fix(agent): sanitize node data and catch addNode side-effect errors

- Convert model's 'border' property to valid 'strokes' array
- Filter null/non-object children before processing
- Wrap docStore.addNode in try-catch — canvas sync side-effects
  (e.g., renderer hitting unknown properties) may throw but the node
  IS still inserted. Return success regardless.

* fix(agent): auto-zoom to fit after insert_node to show new design

* fix(agent): align insert_node with batch_design — auto-replace empty root frame

When inserting a frame at root level and an empty root frame exists,
replace it and inherit its position (x:0, y:0). This matches the MCP
batch_design behavior (line 146-161) so the design lands in the visible
viewport instead of off-screen at x:1250.

* fix(agent): use updateNode for empty frame replacement (no duplicate keys)

The remove+add pattern caused React duplicate key errors because two
separate Zustand state updates could leave the node in two places.
Now uses a single updateNode call to replace the empty frame's
properties in-place — atomic operation, no duplicates.

* fix(agent): set replaced=true BEFORE updateNode to prevent duplicate on side-effect throw

* fix(agent): use atomic tree swap for empty frame replacement

updateNode caused canvas sync crash (map on undefined) because the
renderer couldn't handle a complete property replacement on an existing
node. Now uses removeNodeFromTree + insertNodeInTree in a single
setState — same atomic approach as MCP batch_design. The Skia engine
sees the new node as a fresh addition, not an update to an existing one.

* fix(agent): simplify empty frame replacement to removeNode+addNode via store API

* refactor(agent): use applyNodesToCanvas instead of raw addNode

The agent's insert_node was bypassing the design pipeline's
sanitization and canvas sync code, causing blank canvas rendering.
Now delegates to the SAME applyNodesToCanvas function used by the
CLI/MCP design pipeline. This handles:
- sanitizeNodesForInsert (role defaults, layout fixes, unique IDs)
- isCanvasOnlyEmptyFrame → replaceEmptyFrame (empty frame at 0,0)
- icon resolution and image scanning
- proper Skia canvas sync

Removed the custom postProcessNode method — no longer needed since
applyNodesToCanvas does the same work correctly.

* feat(agent): add generate_design tool using internal CLI design pipeline

generate_design delegates to the SAME generateDesign() function the
standard chat uses — orchestrator, sub-agents, streaming insertion,
post-processing. Finds the first connected CLI provider for LLM calls.
Also uses insertStreamingNode with resetGenerationRemapping() for
insert_node, matching the CLI streaming pattern exactly.

* feat(agent): replace insert_node with generate_design as primary design tool

Remove insert_node and find_empty_space from agent tool set — models
should use generate_design for creating designs, not raw PenNode JSON.
generate_design accepts natural language prompts and delegates to the
full internal pipeline. Keeps update_node/delete_node for modifications.

* feat(agent): simplify system prompt to direct models to generate_design

Clear instruction: when user asks to create/design, MUST call
generate_design tool. No JSON output, no manual node construction.

* fix(agent): strip <think> tags from model text output in chat UI

Models like MiniMax M2.5 and DeepSeek output <think>...</think> as
plain text (not via the thinking SSE event). Strip both closed and
unclosed <think> tags from the displayed text during streaming.

* fix(renderer): skip paragraph image cache when zoomed out below 0.5x

Bitmap text cache at fixed DPR resolution produces jagged edges when
scaled down significantly. Now only uses cache at 0.5x–1x zoom range.
Below 0.5x and above 1x, falls back to vector Paragraph API rendering.

* fix(ai): prevent model from using OpenPencil as brand name in generated designs

* fix(agent): strip <think> tags from final message, not just during streaming

The regex was only applied during text-delta events but the final
message (returned by runAgentStream and set in done/error handlers)
used the raw accumulated text. Now stripThinkTags() is applied at
the return point so the final displayed message is always clean.

* fix(ai): reduce keepalive ping interval from 15s to 5s for Bun compatibility

Bun.serve has a 10s idle timeout. The previous 15s ping interval meant
the first ping arrived AFTER Bun killed the connection, causing
"socket connection was closed unexpectedly" errors when waiting for
slow LLM responses (extended thinking, TTFT > 10s). Now pings at 5s.

* fix(renderer): rasterize paragraph image cache at 2x minimum for crisp text on low-DPR

* fix(agent): remove hardcoded TOOL_SCHEMAS and maxOutputTokens

- Server uses client-provided JSON schemas (single source of truth)
- maxOutputTokens configurable per provider, defaults to model limit
- turnTimeout increased to 5min for generate_design pipeline
- Session cleanup wrapped in try-catch
- Re-export streamText from agent SDK for consumer use

* feat(agent): add builtin provider support to /api/ai/chat endpoint

streamViaBuiltin() uses Vercel AI SDK to call LLM directly with API
key — no CLI tool required. ai-service.ts attaches builtin credentials
from agent settings store when provider is 'builtin'. This enables
generate_design to work without any CLI provider connected.

* fix(agent): align generate_design params with CLI pipeline and handle field name variants

- Pass concurrency, variables, themes, designMd to generateDesign()
  (was missing, causing lower quality output vs CLI mode)
- Accept both 'prompt' and 'description' field names from models
- Fall back to builtin provider when no CLI provider connected
- Enable animated node insertion

* feat(agent): dynamic system prompt via pen-ai-skills + misc fixes

- Replace hardcoded AGENT_SYSTEM_PROMPT with buildAgentSystemPrompt()
  that loads design knowledge from pen-ai-skills (same as CLI pipeline)
- Validate builtin provider apiKey before agent connection
- Client-side tool schema serialization for server (no duplication)
- Update tool_call block status locally as fallback for lost SSE events
- Pass maxOutputTokens from provider config

* refactor(agent): extract builtin provider settings to separate file

Split BuiltinProviderForm, BuiltinProviderCard, BuiltinProvidersSection
from agent-settings-dialog.tsx (1279→730 lines) into
builtin-provider-settings.tsx (394 lines). Both under 800 line limit.

* refactor(agent): extract model selector to separate file

Split ModelDropdown, ConcurrencyButton, resolveNextModel, PROVIDER_ICON
from ai-chat-panel.tsx (915→690 lines) into ai-chat-model-selector.tsx
(183 lines). Both under 800 line limit.

* fix(renderer): guard against undefined fill entries in resolveFillColor/resolveStrokeColor

* feat(ai): extend BuiltinProviderPreset with 9 new providers

Add MiniMax, Zhipu, Kimi, Bailian, DouBao, Xiaomi MiMo, ModelScope,
StepFun and NVIDIA NIM to the builtin provider preset type union.

* feat(ai): add provider presets and region switcher for builtin providers

Add preset configs for MiniMax, Zhipu, Kimi, Bailian (DashScope),
DouBao Seed, Xiaomi MiMo, ModelScope, StepFun and NVIDIA NIM with
correct API base URLs and model placeholders.

Providers with separate China/Global endpoints (MiniMax, Zhipu, Kimi,
Bailian, StepFun) get a region toggle that auto-switches the base URL.
Region inference works both for new providers and when editing existing
ones via REGION_URLS reverse-lookup table.

* style(ai): increase settings dialog height for more provider entries

* feat(ai): add i18n for builtin provider settings (15 locales)

Extract all hardcoded strings in builtin-provider-settings.tsx to
i18n keys under the builtin.* namespace. Add translations for all
15 supported languages: en, zh, zh-TW, ja, ko, fr, es, de, pt,
ru, hi, tr, th, vi, id.

* fix(ai): remove remaining hardcoded strings in builtin provider UI

- Extract error messages, badge text, tooltip, and placeholder to i18n
- Remove unused region label fields from PresetRegion interface
- Add 8 new builtin.* keys across all 15 locales
- Fix ai-chat-model-selector API Key badge and parallel agents tooltip
- Fix ai-chat-panel provider description template
- Fix ai-chat-handlers error messages via i18n.t()

* fix(agent): clean up pending tool calls on exit and improve error handling

P0: Wrap agent loop generator in try-finally to reject all pending tool
call promises and clear timeout timers when the loop exits. Prevents
timeout leaks when sessions end early.

P1: Add HTTP status code matching (429, 402, 403) before regex fallback
in classifyAPIError for more reliable error classification.

P1: Clone tool registry in createTeam instead of mutating the caller's
config.lead.tools. Add doc comment for unused _maxTokens parameter in
sliding-window strategy.

* fix(ai): add error handling and retry for tool result POST

The POST to /api/ai/agent?action=result had no error handling. If it
failed, the agent loop would hang forever waiting for the tool result.
Now retries once on failure and logs the error.

* fix(ai): use lastActivity for session TTL and guard stream init

P1: Add lastActivity timestamp to AgentSession, updated on every event
and tool result. TTL cleanup now checks lastActivity instead of
createdAt, preventing active long-running sessions from being killed.

P1: Wrap ReadableStream construction in try-catch to ensure session
cleanup if stream initialization fails.

* chore: add docs/ to gitignore

* fix(ai): update model lists and fix model search for providers

- Update Anthropic models to include Claude 4.6 (Opus + Sonnet)
- Update model placeholders: OpenAI→gpt-5.4, MiniMax→M2.7,
  Zhipu→glm-5, Kimi→kimi-k2.5, Xiaomi→mimo-v2-pro
- Add hardcoded model lists for MiniMax and DouBao (no /models API)
- Generalize ANTHROPIC_MODELS to BUILTIN_MODEL_LISTS lookup table
- Fix provider-models proxy to handle { models: [...] } and array
  response formats in addition to OpenAI's { data: [...] }

* docs: update CLAUDE.md and README files for new AI agent SDK and i18n support

- Added new sections in CLAUDE.md detailing the AI agent SDK and its multi-provider capabilities.
- Updated README files in multiple languages to include information about the built-in AI agent SDK and full interface localization in 15 languages.
- Adjusted key technologies and scopes to reflect recent changes in the project structure and features.

* docs(agent): add Agent Team web integration design spec

Phase 3 design: model routing via Agent Team. Lead agent uses fast
model for chat, designer member uses capable model for generation.
SDK changes: source tracking on events, member_start/end events,
auto team suffix. Web changes: team toggle + design model selector
in settings, conditional createTeam in server endpoint.

* fix(electron): fix Scoop/Homebrew tap auto-update in CI

- Add pre_install to Scoop manifest to rename versioned exe to OpenPencil.exe
- Add mkdir -p for Casks/ and bucket/ dirs in tap repos
- Use curl -fSL to fail fast on HTTP errors instead of silent mode

* docs(agent): add Agent Team web integration implementation plan

9 tasks: SDK event types → SSE tests → team source injection →
server team endpoint → settings store → team UI → i18n →
chat handler wiring → e2e verification

* feat(agent): add source field and team events to AgentEvent type

* test(agent): add SSE encoder/decoder tests for source and team events

* feat(agent): inject source field and team events in agent-team

* feat(ai): extend agent endpoint to support team mode with members

* feat(ai): add teamEnabled and teamDesignModel to agent settings store

* feat(ai): wire team mode in chat handlers with member events

* feat(ai): add i18n keys for team settings (15 locales)

* feat(ai): add Team section UI with design model selector

* fix(agent): route resolveToolResult to correct agent in team mode

Member tool calls (e.g. generate_design) were routed to leadAgent
which didn't have them in its pending map. Added toolCallOwners map
to track which agent (lead or member) issued each tool call and
route resolveToolResult accordingly.

* docs(agent): clarify resolveToolResult routing, member tools, and source handling

* fix(agent): force delegation in team mode and add turnTimeout support

Two fixes for team mode generation failures:
1. Remove generate_design from lead's tool registry in team mode,
   forcing the lead to delegate design work to the designer member
2. Add turnTimeout to TeamMemberConfig/TeamConfig and pass 5min
   timeout to member agents (generate_design needs it)

* fix(ai): make team mode visually distinct in chat UI

- Add source badge on tool call blocks (shows "designer" pill for
  member-initiated tool calls)
- Pass evt.source to ToolCallBlockData
- Use blockquote format for member_start/end events so team activity
  is clearly visible in the chat stream

* fix(agent): scope designer tools, streamline delegation, add model hint

1. Designer member only gets generate_design + snapshot_layout
   (was: all tools, causing 7 wasteful batch_get calls)
2. Team suffix tells lead to delegate immediately without verbose
   planning text
3. UI shows amber tip when only 2 providers configured, suggesting
   a more capable model for design

* fix(ai): clean up partial nodes on generate_design failure

When generate_design fails midway, partial nodes were left on the
canvas and rootInsertId was not set, allowing a retry that created
a duplicate design. Now:
1. Set rootInsertId='generating' BEFORE calling generateDesign
2. Snapshot node IDs before generation
3. On failure, remove all new nodes and reset rootInsertId to null
4. Remove incorrect same-model warning from Team UI

* fix(ai): show Team section with 1 enabled provider

* refactor(ai): smart team mode — auto-enable when design model is set

Remove manual Team toggle. Selecting a Design Model automatically
enables team mode; "None (single agent)" disables it. Simpler UX:
one dropdown replaces toggle + dropdown. Updated descriptions and
removed obsolete teamTitle/teamSameModelWarning i18n keys.

* fix(agent): properly serialize non-Error objects in error messages

String(err) on plain objects produces "[object Object]". Changed to
err instanceof Error ? err.message : JSON.stringify(err) for readable
error messages in tool results and design generation failures.

* refactor(ai): auto team mode using current chat model

Remove Design Model dropdown from settings. Team mode is now fully
automatic: the designer member always uses the same provider/model
as the current chat selection. Zero configuration needed — the value
is in role separation (different prompts + scoped tools), not model
selection.

* style(ai): use min/max height for settings dialog instead of fixed height

* feat(ai): add Google Gemini to builtin provider presets

Base URL: generativelanguage.googleapis.com/v1beta/openai (OpenAI
compatible). Hardcoded model list: Gemini 2.5 Pro, 2.5 Flash, 2.0
Flash. API key placeholder: AIza...

* feat(ai): update Gemini models to 3.x series

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Kaiiiiiiiii <2761362118@qq.com>
Co-authored-by: Kaiiiiiiiii <182183652+Smile232323@users.noreply.github.com>
This commit is contained in:
Kayshen Xu 2026-03-28 23:12:37 +08:00 committed by GitHub
parent 2cc2447ea0
commit 3c697d817d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
151 changed files with 9474 additions and 925 deletions

View file

@ -119,3 +119,133 @@ jobs:
files: artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew:
name: Update Homebrew Cask
runs-on: ubuntu-latest
needs: release
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Get version
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Download macOS zips and compute sha256
run: |
VERSION=${{ steps.version.outputs.version }}
curl -fSL "https://github.com/zseven-w/openpencil/releases/download/v${VERSION}/OpenPencil-${VERSION}-arm64-mac.zip" -o arm64.zip
curl -fSL "https://github.com/zseven-w/openpencil/releases/download/v${VERSION}/OpenPencil-${VERSION}-x64-mac.zip" -o x64.zip
echo "SHA_ARM64=$(sha256sum arm64.zip | cut -d' ' -f1)" >> "$GITHUB_ENV"
echo "SHA_X64=$(sha256sum x64.zip | cut -d' ' -f1)" >> "$GITHUB_ENV"
- name: Checkout tap repo
uses: actions/checkout@v4
with:
repository: zseven-w/homebrew-openpencil
token: ${{ secrets.TAP_GITHUB_TOKEN }}
- name: Update cask formula
run: |
VERSION=${{ steps.version.outputs.version }}
mkdir -p Casks
cat > Casks/openpencil.rb << EOF
cask "openpencil" do
version "${VERSION}"
on_arm do
sha256 "${SHA_ARM64}"
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/OpenPencil-#{version}-arm64-mac.zip"
end
on_intel do
sha256 "${SHA_X64}"
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/OpenPencil-#{version}-x64-mac.zip"
end
name "OpenPencil"
desc "Open-source vector design tool"
homepage "https://github.com/zseven-w/openpencil"
app "OpenPencil.app"
zap trash: [
"~/Library/Application Support/OpenPencil",
"~/Library/Preferences/dev.openpencil.app.plist",
"~/Library/Caches/dev.openpencil.app",
]
end
EOF
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/openpencil.rb
git diff --cached --quiet && exit 0
git commit -m "Update OpenPencil to ${{ steps.version.outputs.version }}"
git push
update-scoop:
name: Update Scoop Bucket
runs-on: ubuntu-latest
needs: release
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Get version
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Download Windows portable and compute sha256
run: |
VERSION=${{ steps.version.outputs.version }}
curl -fSL "https://github.com/zseven-w/openpencil/releases/download/v${VERSION}/OpenPencil-${VERSION}-x64-win.exe" -o win64.exe
echo "SHA_WIN64=$(sha256sum win64.exe | cut -d' ' -f1)" >> "$GITHUB_ENV"
- name: Checkout scoop bucket
uses: actions/checkout@v4
with:
repository: zseven-w/scoop-openpencil
token: ${{ secrets.TAP_GITHUB_TOKEN }}
- name: Update manifest
run: |
VERSION=${{ steps.version.outputs.version }}
mkdir -p bucket
cat > bucket/openpencil.json << EOF
{
"version": "${VERSION}",
"description": "Open-source AI-native vector design tool",
"homepage": "https://github.com/zseven-w/openpencil",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v${VERSION}/OpenPencil-${VERSION}-x64-win.exe",
"hash": "${SHA_WIN64}"
}
},
"pre_install": "Rename-Item \"\$dir\\OpenPencil-\$version-x64-win.exe\" 'OpenPencil.exe'",
"bin": "OpenPencil.exe",
"shortcuts": [["OpenPencil.exe", "OpenPencil"]],
"checkver": {
"github": "https://github.com/zseven-w/openpencil"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v\$version/OpenPencil-\$version-x64-win.exe"
}
},
"pre_install": "Rename-Item \"\$dir\\OpenPencil-\$version-x64-win.exe\" 'OpenPencil.exe'"
}
}
EOF
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add bucket/openpencil.json
git diff --cached --quiet && exit 0
git commit -m "Update OpenPencil to ${{ steps.version.outputs.version }}"
git push

View file

@ -34,15 +34,6 @@ jobs:
id: version
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
- name: Check if version already published
run: |
VERSION=${{ steps.version.outputs.version }}
if npm view "@zseven-w/pen-types@${VERSION}" version 2>/dev/null; then
echo "::error::Version ${VERSION} already exists on npm. Aborting."
exit 1
fi
echo "Version ${VERSION} is not yet published. Proceeding."
- name: Replace workspace:* with version
run: |
VERSION=${{ steps.version.outputs.version }}
@ -70,45 +61,51 @@ jobs:
- name: Verify CLI build
run: node apps/cli/dist/openpencil-cli.cjs --version
# Publish in topological order — fail fast on error
# Publish in topological order — skip packages already published at this version
- name: Publish pen-types
run: npm publish --access public
run: npm publish --access public 2>&1 || [[ $? -eq 0 ]] || npm view "@zseven-w/pen-types@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-types
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-core
run: npm publish --access public
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-core@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-core
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-codegen
run: npm publish --access public
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-codegen@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-codegen
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-figma
run: npm publish --access public
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-figma@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-figma
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-renderer
run: npm publish --access public
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-renderer@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-renderer
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-sdk
run: npm publish --access public
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-sdk@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-sdk
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-ai-skills
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-ai-skills@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-ai-skills
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI
run: npm publish --access public
run: npm publish --access public 2>&1 || npm view "@zseven-w/openpencil-cli@${{ steps.version.outputs.version }}" version
working-directory: apps/cli
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View file

@ -11,9 +11,12 @@ __unconfig*
todos.json
.openpencil-tmp/
docs/
# Build outputs
out/
dist/
dist-ssr/
electron-dist/
dist-electron/
.claude

View file

@ -37,12 +37,13 @@ openpencil/
│ ├── pen-figma/ Figma .fig file parser and converter
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
│ ├── pen-sdk/ Umbrella SDK (re-exports all packages)
│ └── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory)
│ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory)
│ └── agent/ Domain-agnostic AI agent SDK (Vercel AI SDK, multi-provider, agent teams)
├── scripts/ Build and publish scripts
└── .githooks/ Pre-commit version sync from branch name
```
**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode).
**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode).
### Data Flow
@ -137,7 +138,7 @@ Use [Conventional Commits](https://www.conventionalcommits.org/) format: `<type>
**Types:** `feat`, `fix`, `refactor`, `perf`, `style`, `docs`, `test`, `chore`
**Scopes:** `editor`, `canvas`, `panels`, `history`, `ai`, `codegen`, `store`, `types`, `variables`, `figma`, `mcp`, `electron`, `renderer`, `sdk`, `cli`
**Scopes:** `editor`, `canvas`, `panels`, `history`, `ai`, `codegen`, `store`, `types`, `variables`, `figma`, `mcp`, `electron`, `renderer`, `sdk`, `cli`, `agent`, `i18n`
**Rules:** Subject in English, lowercase start, no period, imperative mood. Body is optional; explain **why** not what. One commit per change.

View file

@ -10,6 +10,7 @@ COPY packages/pen-figma/package.json packages/pen-figma/
COPY packages/pen-renderer/package.json packages/pen-renderer/
COPY packages/pen-sdk/package.json packages/pen-sdk/
COPY packages/pen-ai-skills/package.json packages/pen-ai-skills/
COPY packages/agent/package.json packages/agent/
COPY apps/web/package.json apps/web/
COPY apps/desktop/package.json apps/desktop/
COPY apps/cli/package.json apps/cli/

View file

@ -31,6 +31,8 @@
<br />
> **Hinweis:** Es gibt ein weiteres Open-Source-Projekt mit demselben Namen — [OpenPencil](https://github.com/open-pencil/open-pencil), das sich auf Figma-kompatibles visuelles Design mit Echtzeit-Zusammenarbeit konzentriert. Dieses Projekt konzentriert sich auf AI-native Design-to-Code-Workflows.
## Warum OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Agent | Einrichtung |
| --- | --- |
| **Integriert (9+ Anbieter)** | Auswahl aus Anbieter-Presets mit Region-Switcher — Anthropic, OpenAI, Google, DeepSeek und mehr |
| **Claude Code** | Keine Konfiguration — verwendet Claude Agent SDK mit lokalem OAuth |
| **Codex CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
| **OpenCode** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Modell-Fähigkeitsprofile** — passt Prompts, Thinking-Modus und Timeouts automatisch pro Modellstufe an. Modelle der Vollstufe (Claude) erhalten vollständige Prompts; Standardstufe (GPT-4o, Gemini, DeepSeek) deaktiviert Thinking; Basisstufe (MiniMax, Qwen, Llama, Mistral) erhält vereinfachte verschachtelte JSON-Prompts für maximale Zuverlässigkeit.
**i18n** — Vollständige Interface-Lokalisierung in 15 Sprachen: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**MCP-Server**
- Eingebauter MCP-Server — Ein-Klick-Installation in Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Automatische Node.js-Erkennung — falls nicht installiert, automatischer Fallback auf HTTP-Transport und automatischer Start des MCP-HTTP-Servers
@ -219,6 +224,8 @@ cat design.dsl | op design - # Pipe von stdin
Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen) oder `-` (von stdin lesen). Funktioniert mit der Desktop-App oder dem Web-Entwicklungsserver. Siehe [CLI README](./apps/cli/README.md) für die vollständige Befehlsreferenz.
**LLM-Skill** — Installieren Sie das [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill)-Plugin, um KI-Agenten (Claude Code, Cursor, Codex, Gemini CLI usw.) das Designen mit `op` beizubringen.
## Funktionen
**Canvas und Zeichnen**
@ -248,13 +255,13 @@ Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen)
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Canvas** | CanvasKit/Skia (WASM, GPU-beschleunigt) |
| **State** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
| **CLI** | `op` — Terminal-Steuerung, Batch-Design-DSL, Code-Export |
| **KI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **KI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Laufzeit** | Bun · Vite 7 |
| **Dateiformat** | `.op` — JSON-basiert, menschenlesbar, Git-freundlich |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Codegeneratoren (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Figma-.fig-Datei-Parser und -Konverter
│ ├── pen-renderer/ Eigenständiger CanvasKit/Skia-Renderer
│ └── pen-sdk/ Umbrella-SDK (re-exportiert alle Pakete)
│ ├── pen-sdk/ Umbrella-SDK (re-exportiert alle Pakete)
│ ├── pen-ai-skills/ KI-Prompt-Skill-Engine (phasengesteuertes Prompt-Laden)
│ └── agent/ KI-Agenten-SDK (Vercel AI SDK, Multi-Anbieter, Agententeams)
└── .githooks/ Pre-Commit-Versionssynchronisierung vom Branch-Namen
```
@ -348,6 +357,8 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail
- [x] Multi-Modell-Fähigkeitsprofile
- [x] Monorepo-Umstrukturierung mit wiederverwendbaren Paketen
- [x] CLI-Tool (`op`) für Terminal-Steuerung
- [x] Integriertes KI-Agenten-SDK mit Multi-Anbieter-Unterstützung
- [x] i18n — 15 Sprachen
- [ ] Kollaboratives Bearbeiten
- [ ] Plugin-System

View file

@ -31,6 +31,8 @@
<br />
> **Nota:** Existe otro proyecto de código abierto con el mismo nombre — [OpenPencil](https://github.com/open-pencil/open-pencil), enfocado en diseño visual compatible con Figma con colaboración en tiempo real. Este proyecto se enfoca en flujos de trabajo AI-nativos de diseño a código.
## Por Qué OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Agente | Configuración |
| --- | --- |
| **Integrado (9+ proveedores)** | Selecciona de preajustes de proveedores con selector de región — Anthropic, OpenAI, Google, DeepSeek y más |
| **Claude Code** | Sin configuración — usa Claude Agent SDK con OAuth local |
| **Codex CLI** | Conectar en Configuración de Agente (`Cmd+,`) |
| **OpenCode** | Conectar en Configuración de Agente (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Perfiles de Capacidad de Modelos** — adapta automáticamente los prompts, el modo de pensamiento y los tiempos de espera según el nivel del modelo. Los modelos de nivel completo (Claude) reciben prompts completos; los de nivel estándar (GPT-4o, Gemini, DeepSeek) desactivan el pensamiento; los de nivel básico (MiniMax, Qwen, Llama, Mistral) reciben prompts simplificados de JSON anidado para máxima fiabilidad.
**i18n** — Localización completa de la interfaz en 15 idiomas: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**Servidor MCP**
- Servidor MCP integrado — instalación con un clic en Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Detección automática de Node.js — si no está instalado, recurre automáticamente al transporte HTTP e inicia el servidor MCP HTTP
@ -219,6 +224,8 @@ cat design.dsl | op design - # Entrada por pipe desde stdin
Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo), o `-` (leer desde stdin). Funciona con la app de escritorio o el servidor de desarrollo web. Consulta el [README del CLI](./apps/cli/README.md) para la referencia completa de comandos.
**Habilidad LLM** — instala el plugin [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) para enseñar a agentes IA (Claude Code, Cursor, Codex, Gemini CLI, etc.) a diseñar con `op`.
## Características
**Lienzo y Dibujo**
@ -248,13 +255,13 @@ Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Lienzo** | CanvasKit/Skia (WASM, acelerado por GPU) |
| **Estado** | Zustand v5 |
| **Servidor** | Nitro |
| **Escritorio** | Electron 35 |
| **CLI** | `op` — control desde terminal, DSL de diseño por lotes, exportación de código |
| **IA** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Runtime** | Bun · Vite 7 |
| **Formato de archivo** | `.op` — basado en JSON, legible por humanos, compatible con Git |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Generadores de código (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Parser y conversor de archivos .fig de Figma
│ ├── pen-renderer/ Renderizador independiente CanvasKit/Skia
│ └── pen-sdk/ SDK global (reexporta todos los paquetes)
│ ├── pen-sdk/ SDK global (reexporta todos los paquetes)
│ ├── pen-ai-skills/ Motor de habilidades AI (carga de prompts por fases)
│ └── agent/ SDK de agente AI (Vercel AI SDK, multi-proveedor, equipos de agentes)
└── .githooks/ Sincronización de versión pre-commit desde nombre de rama
```
@ -348,6 +357,8 @@ bun run cli:compile # Compilar CLI a dist
- [x] Perfiles de capacidad multimodelo
- [x] Reestructuración en monorepo con paquetes reutilizables
- [x] Herramienta CLI (`op`) para control desde terminal
- [x] SDK de agente AI integrado con soporte multi-proveedor
- [x] i18n — 15 idiomas
- [ ] Edición colaborativa
- [ ] Sistema de plugins

View file

@ -31,6 +31,8 @@
<br />
> **Note :** Il existe un autre projet open-source portant le même nom — [OpenPencil](https://github.com/open-pencil/open-pencil), axé sur le design visuel compatible Figma avec collaboration en temps réel. Ce projet est axé sur les workflows AI-natifs de design vers code.
## Pourquoi OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Agent | Configuration |
| --- | --- |
| **Intégré (9+ fournisseurs)** | Choisissez parmi les préréglages de fournisseurs avec sélecteur de région — Anthropic, OpenAI, Google, DeepSeek et plus |
| **Claude Code** | Aucune configuration — utilise le Claude Agent SDK avec OAuth local |
| **Codex CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
| **OpenCode** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Profils de capacités des modèles** — adapte automatiquement les prompts, le mode de réflexion et les délais d'attente par niveau de modèle. Les modèles de niveau complet (Claude) reçoivent des prompts complets ; le niveau standard (GPT-4o, Gemini, DeepSeek) désactive la réflexion ; le niveau basique (MiniMax, Qwen, Llama, Mistral) reçoit des prompts JSON imbriqués simplifiés pour une fiabilité maximale.
**i18n** — Localisation complète de l'interface en 15 langues : English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**Serveur MCP**
- Serveur MCP intégré — installation en un clic dans les CLI Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot
- Détection automatique de Node.js — si non installé, bascule vers le transport HTTP et démarre automatiquement le serveur MCP HTTP
@ -219,6 +224,8 @@ cat design.dsl | op design - # Pipe depuis stdin
Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depuis un fichier), ou `-` (lecture depuis stdin). Fonctionne avec l'app de bureau ou le serveur de développement web. Voir le [README du CLI](./apps/cli/README.md) pour la référence complète des commandes.
**Compétence LLM** — installez le plugin [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) pour apprendre aux agents IA (Claude Code, Cursor, Codex, Gemini CLI, etc.) à concevoir avec `op`.
## Fonctionnalités
**Canevas et dessin**
@ -248,13 +255,13 @@ Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depu
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Canevas** | CanvasKit/Skia (WASM, accélération GPU) |
| **État** | Zustand v5 |
| **Serveur** | Nitro |
| **Bureau** | Electron 35 |
| **CLI** | `op` — contrôle depuis le terminal, DSL de design par lots, export de code |
| **IA** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Runtime** | Bun · Vite 7 |
| **Format de fichier** | `.op` — basé sur JSON, lisible par l'humain, compatible Git |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Générateurs de code (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Parseur et convertisseur de fichiers Figma .fig
│ ├── pen-renderer/ Moteur de rendu CanvasKit/Skia autonome
│ └── pen-sdk/ SDK parapluie (réexporte tous les packages)
│ ├── pen-sdk/ SDK parapluie (réexporte tous les packages)
│ ├── pen-ai-skills/ Moteur de compétences AI (chargement de prompts par phases)
│ └── agent/ SDK agent AI (Vercel AI SDK, multi-fournisseur, équipes d'agents)
└── .githooks/ Synchronisation de version pre-commit depuis le nom de branche
```
@ -348,6 +357,8 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour
- [x] Profils de capacités multi-modèles
- [x] Restructuration en monorepo avec packages réutilisables
- [x] Outil CLI (`op`) pour le contrôle depuis le terminal
- [x] SDK agent AI intégré avec support multi-fournisseurs
- [x] i18n — 15 langues
- [ ] Édition collaborative
- [ ] Système de plugins

View file

@ -31,6 +31,8 @@
<br />
> **नोट:** इसी नाम का एक और ओपन-सोर्स प्रोजेक्ट है — [OpenPencil](https://github.com/open-pencil/open-pencil), जो रियल-टाइम सहयोग के साथ Figma-संगत विज़ुअल डिज़ाइन पर केंद्रित है। यह प्रोजेक्ट AI-नेटिव डिज़ाइन-टू-कोड वर्कफ़्लो पर केंद्रित है।
## OpenPencil क्यों
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| एजेंट | सेटअप |
| --- | --- |
| **बिल्ट-इन (9+ प्रदाता)** | प्रदाता प्रीसेट से चुनें और क्षेत्र स्विच करें — Anthropic, OpenAI, Google, DeepSeek और अन्य |
| **Claude Code** | कोई कॉन्फ़िग नहीं — लोकल OAuth के साथ Claude Agent SDK का उपयोग करता है |
| **Codex CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
| **OpenCode** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**मॉडल क्षमता प्रोफ़ाइल** — प्रत्येक मॉडल टियर के अनुसार प्रॉम्प्ट, थिंकिंग मोड और टाइमआउट को स्वचालित रूप से अनुकूलित करता है। फुल-टियर मॉडल (Claude) को पूर्ण प्रॉम्प्ट मिलते हैं; स्टैंडर्ड-टियर (GPT-4o, Gemini, DeepSeek) में थिंकिंग अक्षम होती है; बेसिक-टियर (MiniMax, Qwen, Llama, Mistral) को अधिकतम विश्वसनीयता के लिए सरलीकृत नेस्टेड-JSON प्रॉम्प्ट मिलते हैं।
**i18n** — 15 भाषाओं में पूर्ण इंटरफ़ेस स्थानीयकरण: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia।
**MCP सर्वर**
- बिल्ट-इन MCP सर्वर — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs में वन-क्लिक इंस्टॉल
- Node.js स्वचालित पहचान — यदि इंस्टॉल नहीं है तो HTTP ट्रांसपोर्ट पर स्वचालित फ़ॉलबैक और MCP HTTP सर्वर ऑटो-स्टार्ट
@ -219,6 +224,8 @@ cat design.dsl | op design - # stdin से पाइप करें
तीन इनपुट विधियाँ समर्थित हैं: इनलाइन स्ट्रिंग, `@filepath` (फ़ाइल से पढ़ें), या `-` (stdin से पढ़ें)। डेस्कटॉप ऐप या वेब डेव सर्वर के साथ काम करता है। पूर्ण कमांड संदर्भ के लिए [CLI README](./apps/cli/README.md) देखें।
**LLM स्किल** — [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) प्लगइन इंस्टॉल करें ताकि AI एजेंट (Claude Code, Cursor, Codex, Gemini CLI आदि) `op` से डिज़ाइन करना सीख सकें।
## विशेषताएँ
**कैनवास और ड्रॉइंग**
@ -248,13 +255,13 @@ cat design.dsl | op design - # stdin से पाइप करें
| | |
| --- | --- |
| **फ्रंटएंड** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **फ्रंटएंड** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **कैनवास** | CanvasKit/Skia (WASM, GPU-एक्सेलेरेटेड) |
| **स्टेट** | Zustand v5 |
| **सर्वर** | Nitro |
| **डेस्कटॉप** | Electron 35 |
| **CLI** | `op` — टर्मिनल नियंत्रण, बैच डिज़ाइन DSL, कोड एक्सपोर्ट |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **रनटाइम** | Bun · Vite 7 |
| **फ़ाइल फ़ॉर्मेट** | `.op` — JSON-आधारित, मानव-पठनीय, Git-फ्रेंडली |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ कोड जनरेटर (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Figma .fig फ़ाइल पार्सर और कनवर्टर
│ ├── pen-renderer/ स्टैंडअलोन CanvasKit/Skia रेंडरर
│ └── pen-sdk/ अम्ब्रेला SDK (सभी पैकेज री-एक्सपोर्ट)
│ ├── pen-sdk/ अम्ब्रेला SDK (सभी पैकेज री-एक्सपोर्ट)
│ ├── pen-ai-skills/ AI प्रॉम्प्ट स्किल इंजन (चरणबद्ध प्रॉम्प्ट लोडिंग)
│ └── agent/ AI एजेंट SDK (Vercel AI SDK, मल्टी-प्रदाता, एजेंट टीमें)
└── .githooks/ ब्रांच नाम से प्री-कमिट वर्शन सिंक
```
@ -348,6 +357,8 @@ bun run cli:compile # CLI को dist में कंपाइल कर
- [x] मल्टी-मॉडल क्षमता प्रोफ़ाइल
- [x] पुन: उपयोगी पैकेज के साथ मोनोरेपो पुनर्गठन
- [x] CLI टूल (`op`) टर्मिनल नियंत्रण
- [x] बिल्ट-इन AI एजेंट SDK, मल्टी-प्रदाता समर्थन
- [x] i18n — 15 भाषाएँ
- [ ] सहयोगी संपादन
- [ ] प्लगइन सिस्टम

View file

@ -31,6 +31,8 @@
<br />
> **Catatan:** Ada proyek open-source lain dengan nama yang sama — [OpenPencil](https://github.com/open-pencil/open-pencil), yang berfokus pada desain visual kompatibel Figma dengan kolaborasi real-time. Proyek ini berfokus pada alur kerja AI-native dari desain ke kode.
## Mengapa OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Agen | Pengaturan |
| --- | --- |
| **Bawaan (9+ penyedia)** | Pilih dari preset penyedia dengan pemilih wilayah — Anthropic, OpenAI, Google, DeepSeek dan lainnya |
| **Claude Code** | Tanpa konfigurasi — menggunakan Claude Agent SDK dengan OAuth lokal |
| **Codex CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
| **OpenCode** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Profil Kemampuan Model** — secara otomatis menyesuaikan prompt, mode thinking, dan timeout per tingkatan model. Model tingkat penuh (Claude) mendapat prompt lengkap; tingkat standar (GPT-4o, Gemini, DeepSeek) menonaktifkan thinking; tingkat dasar (MiniMax, Qwen, Llama, Mistral) mendapat prompt JSON bertingkat yang disederhanakan untuk keandalan maksimum.
**i18n** — Lokalisasi antarmuka lengkap dalam 15 bahasa: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**Server MCP**
- Server MCP bawaan — instal satu klik ke Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- Deteksi otomatis Node.js — jika tidak terinstal, otomatis beralih ke transport HTTP dan memulai server MCP HTTP
@ -219,6 +224,8 @@ cat design.dsl | op design - # Pipe dari stdin
Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau `-` (baca dari stdin). Bekerja dengan aplikasi desktop atau web dev server. Lihat [CLI README](./apps/cli/README.md) untuk referensi perintah lengkap.
**LLM Skill** — instal plugin [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) untuk mengajarkan agen AI (Claude Code, Cursor, Codex, Gemini CLI, dll.) mendesain dengan `op`.
## Fitur
**Kanvas & Menggambar**
@ -248,13 +255,13 @@ Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau `
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Kanvas** | CanvasKit/Skia (WASM, akselerasi GPU) |
| **State** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
| **CLI** | `op` — kontrol terminal, batch design DSL, ekspor kode |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Runtime** | Bun · Vite 7 |
| **Format file** | `.op` — berbasis JSON, mudah dibaca manusia, ramah Git |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Generator kode (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Parser dan konverter file Figma .fig
│ ├── pen-renderer/ Renderer CanvasKit/Skia mandiri
│ └── pen-sdk/ SDK payung (re-ekspor semua paket)
│ ├── pen-sdk/ SDK payung (re-ekspor semua paket)
│ ├── pen-ai-skills/ Engine skill AI prompt (pemuatan prompt bertahap)
│ └── agent/ SDK agen AI (Vercel AI SDK, multi-penyedia, tim agen)
└── .githooks/ Pre-commit sinkronisasi versi dari nama branch
```
@ -348,6 +357,8 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt
- [x] Profil kemampuan multi-model
- [x] Restrukturisasi monorepo dengan paket yang dapat digunakan ulang
- [x] Alat CLI (`op`) kontrol terminal
- [x] SDK agen AI bawaan dengan dukungan multi-penyedia
- [x] i18n — 15 bahasa
- [ ] Pengeditan kolaboratif
- [ ] Sistem plugin

View file

@ -31,6 +31,8 @@
<br />
> **注:** 同名の別のオープンソースプロジェクト — [OpenPencil](https://github.com/open-pencil/open-pencil) があります。そちらは Figma 互換のビジュアルデザインとリアルタイムコラボレーションに特化しています。本プロジェクトは AI ネイティブのデザインからコードへのワークフローに特化しています。
## Why OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| エージェント | 設定方法 |
| --- | --- |
| **ビルトイン9+ プロバイダー)** | プロバイダープリセットから選択し、リージョンを切り替え — Anthropic、OpenAI、Google、DeepSeek など |
| **Claude Code** | 設定不要 — ローカル OAuth で Claude Agent SDK を使用 |
| **Codex CLI** | エージェント設定で接続(`Cmd+,` |
| **OpenCode** | エージェント設定で接続(`Cmd+,` |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**モデル能力プロファイル** — モデルの階層に応じてプロンプト、シンキングモード、タイムアウトを自動適応。フル階層モデルClaudeには完全なプロンプト、標準階層GPT-4o、Gemini、DeepSeekではシンキングを無効化、ベーシック階層MiniMax、Qwen、Llama、Mistralには最大限の信頼性のために簡略化されたネスト JSON プロンプトを使用。
**i18n** — 15言語での完全なインターフェースローカライゼーションEnglish、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。
**MCP サーバー**
- 内蔵 MCP サーバー — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI にワンクリックでインストール
- Node.js を自動検出 — 未インストールの場合は HTTP トランスポートに自動フォールバックし、MCP HTTP サーバーを自動起動
@ -219,6 +224,8 @@ cat design.dsl | op design - # stdin からパイプ入力
3つの入力方法に対応インライン文字列、`@filepath`(ファイルから読み込み)、`-`stdin から読み込み)。デスクトップアプリまたは Web 開発サーバーと連携。完全なコマンドリファレンスは [CLI README](./apps/cli/README.md) を参照。
**LLM スキル** — [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) プラグインをインストールすると、AIエージェントClaude Code、Cursor、Codex、Gemini CLI など)に `op` を使ったデザインを教えられます。
## 機能
**キャンバスと描画**
@ -248,13 +255,13 @@ cat design.dsl | op design - # stdin からパイプ入力
| | |
| --- | --- |
| **フロントエンド** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **フロントエンド** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **キャンバス** | CanvasKit/SkiaWASM、GPU アクセラレーション) |
| **状態管理** | Zustand v5 |
| **サーバー** | Nitro |
| **デスクトップ** | Electron 35 |
| **CLI** | `op` — ターミナル制御、バッチデザインDSL、コードエクスポート |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **ランタイム** | Bun · Vite 7 |
| **ファイル形式** | `.op` — JSON ベース、人間が読みやすく、Git フレンドリー |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ コードジェネレーターReact、HTML、Vue、Flutter、...
│ ├── pen-figma/ Figma .fig ファイルパーサーとコンバーター
│ ├── pen-renderer/ スタンドアロン CanvasKit/Skia レンダラー
│ └── pen-sdk/ アンブレラ SDK全パッケージの再エクスポート
│ ├── pen-sdk/ アンブレラ SDK全パッケージの再エクスポート
│ ├── pen-ai-skills/ AI プロンプトスキルエンジン(フェーズ駆動プロンプト読込)
│ └── agent/ AI エージェント SDKVercel AI SDK、マルチプロバイダー、エージェントチーム
└── .githooks/ ブランチ名からのプレコミットバージョン同期
```
@ -348,6 +357,8 @@ bun run cli:compile # CLI を dist にコンパイル
- [x] マルチモデル能力プロファイル
- [x] 再利用可能なパッケージによるモノレポ構成
- [x] CLIツール`op`)ターミナル制御
- [x] ビルトイン AI エージェント SDKマルチプロバイダー対応
- [x] i18n — 15言語対応
- [ ] 共同編集
- [ ] プラグインシステム

View file

@ -31,6 +31,8 @@
<br />
> **참고:** 같은 이름의 다른 오픈소스 프로젝트가 있습니다 — [OpenPencil](https://github.com/open-pencil/open-pencil). 해당 프로젝트는 Figma 호환 비주얼 디자인과 실시간 협업에 중점을 둡니다. 본 프로젝트는 AI 네이티브 디자인-투-코드 워크플로에 중점을 둡니다.
## OpenPencil을 선택하는 이유
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| 에이전트 | 설정 방법 |
| --- | --- |
| **내장 (9+ 제공자)** | 제공자 프리셋에서 선택하고 지역을 전환 — Anthropic, OpenAI, Google, DeepSeek 등 |
| **Claude Code** | 설정 불필요 — 로컬 OAuth로 Claude Agent SDK 사용 |
| **Codex CLI** | 에이전트 설정에서 연결 (`Cmd+,`) |
| **OpenCode** | 에이전트 설정에서 연결 (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**모델 역량 프로파일** — 모델 티어에 따라 프롬프트, 사고 모드, 타임아웃을 자동 조정합니다. 풀 티어 모델(Claude)은 완전한 프롬프트를 받고, 스탠다드 티어(GPT-4o, Gemini, DeepSeek)는 사고 모드를 비활성화하며, 베이직 티어(MiniMax, Qwen, Llama, Mistral)는 최대 안정성을 위해 단순화된 중첩 JSON 프롬프트를 받습니다.
**i18n** — 15개 언어로 완전한 인터페이스 지역화: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**MCP 서버**
- 내장 MCP 서버 — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI에 원클릭 설치
- Node.js 자동 감지 — 설치되지 않은 경우 HTTP 전송 모드로 자동 대체하고 MCP HTTP 서버를 자동 시작
@ -219,6 +224,8 @@ cat design.dsl | op design - # stdin에서 파이프 입력
세 가지 입력 방식을 지원합니다: 인라인 문자열, `@filepath` (파일에서 읽기), `-` (stdin에서 읽기). 데스크톱 앱 또는 웹 개발 서버와 연동됩니다. 전체 명령어 레퍼런스는 [CLI README](./apps/cli/README.md)를 참고하세요.
**LLM 스킬** — [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) 플러그인을 설치하면 AI 에이전트(Claude Code, Cursor, Codex, Gemini CLI 등)에게 `op`를 사용한 디자인을 교육할 수 있습니다.
## 기능
**캔버스 & 드로잉**
@ -248,13 +255,13 @@ cat design.dsl | op design - # stdin에서 파이프 입력
| | |
| --- | --- |
| **프론트엔드** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **프론트엔드** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **캔버스** | CanvasKit/Skia (WASM, GPU 가속) |
| **상태 관리** | Zustand v5 |
| **서버** | Nitro |
| **데스크톱** | Electron 35 |
| **CLI** | `op` — 터미널 제어, 배치 디자인 DSL, 코드 내보내기 |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **런타임** | Bun · Vite 7 |
| **파일 형식** | `.op` — JSON 기반, 사람이 읽을 수 있는, Git 친화적 |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ 코드 생성기 (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Figma .fig 파일 파서 및 변환기
│ ├── pen-renderer/ 독립형 CanvasKit/Skia 렌더러
│ └── pen-sdk/ 통합 SDK (모든 패키지 재export)
│ ├── pen-sdk/ 통합 SDK (모든 패키지 재export)
│ ├── pen-ai-skills/ AI 프롬프트 스킬 엔진 (단계별 프롬프트 로딩)
│ └── agent/ AI 에이전트 SDK (Vercel AI SDK, 멀티 제공자, 에이전트 팀)
└── .githooks/ 브랜치 이름에서 버전 동기화를 위한 pre-commit
```
@ -348,6 +357,8 @@ bun run cli:compile # CLI를 dist로 컴파일
- [x] 멀티 모델 역량 프로파일
- [x] 재사용 가능한 패키지를 포함한 모노레포 구조 변경
- [x] CLI 도구 (`op`) 터미널 제어
- [x] 내장 AI 에이전트 SDK (멀티 제공자 지원)
- [x] i18n — 15개 언어
- [ ] 공동 편집
- [ ] 플러그인 시스템

View file

@ -31,6 +31,8 @@
<br />
> **Note:** There is another open-source project with the same name — [OpenPencil](https://github.com/open-pencil/open-pencil), focused on Figma-compatible visual design with real-time collaboration. This project focuses on AI-native design-to-code workflows.
## Why OpenPencil
<table>
@ -100,7 +102,31 @@ Export to React + Tailwind, HTML + CSS, Vue, Svelte, Flutter, SwiftUI, Jetpack C
</tr>
</table>
## Quick Start
## Install
**macOS (Homebrew):**
```bash
brew tap zseven-w/openpencil
brew install --cask openpencil
```
**Windows (Scoop):**
```powershell
scoop bucket add openpencil https://github.com/zseven-w/scoop-openpencil
scoop install openpencil
```
**Linux / Windows direct download:** [GitHub Releases](https://github.com/ZSeven-W/openpencil/releases) — `.exe` (Windows), `.AppImage` / `.deb` (Linux)
**CLI (`op`):**
```bash
npm install -g @zseven-w/openpencil
```
## Quick Start (Development)
```bash
# Install dependencies
@ -180,6 +206,7 @@ docker build --target full -t openpencil-full .
| Agent | Setup |
| --- | --- |
| **Built-in (9+ providers)** | Select from provider presets with region switcher — Anthropic, OpenAI, Google, DeepSeek, and more |
| **Claude Code** | No config — uses Claude Agent SDK with local OAuth |
| **Codex CLI** | Connect in Agent Settings (`Cmd+,`) |
| **OpenCode** | Connect in Agent Settings (`Cmd+,`) |
@ -188,6 +215,8 @@ docker build --target full -t openpencil-full .
**Model Capability Profiles** — automatically adapts prompts, thinking mode, and timeouts per model tier. Full-tier models (Claude) get complete prompts; standard-tier (GPT-4o, Gemini, DeepSeek) disable thinking; basic-tier (MiniMax, Qwen, Llama, Mistral) get simplified nested-JSON prompts for maximum reliability.
**i18n** — Full interface localization in 15 languages: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**MCP Server**
- Built-in MCP server — one-click install into Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Auto-detects Node.js — if not installed, falls back to HTTP transport and auto-starts the MCP HTTP server
@ -219,6 +248,8 @@ cat design.dsl | op design - # Pipe from stdin
Supports three input methods: inline string, `@filepath` (read from file), or `-` (read from stdin). Works with desktop app or web dev server. See [CLI README](./apps/cli/README.md) for full command reference.
**LLM Skill** — install the [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) plugin to teach AI agents (Claude Code, Cursor, Codex, Gemini CLI, etc.) how to design with `op`.
## Features
**Canvas & Drawing**
@ -248,13 +279,13 @@ Supports three input methods: inline string, `@filepath` (read from file), or `-
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
| **State** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
| **CLI** | `op` — terminal control, batch design DSL, code export |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Runtime** | Bun · Vite 7 |
| **File format** | `.op` — JSON-based, human-readable, Git-friendly |
@ -268,13 +299,16 @@ openpencil/
│ │ │ ├── canvas/ CanvasKit/Skia engine — drawing, sync, layout
│ │ │ ├── components/ React UI — editor, panels, shared dialogs, icons
│ │ │ ├── services/ai/ AI chat, orchestrator, design generation, streaming
│ │ │ ├── services/codegen/ Code generation service wrappers
│ │ │ ├── stores/ Zustand — canvas, document, pages, history, AI
│ │ │ ├── mcp/ MCP server tools for external CLI integration
│ │ │ ├── hooks/ Keyboard shortcuts, file drop, Figma paste
│ │ │ ├── hooks/ Keyboard shortcuts, file drop, Figma paste, MCP sync
│ │ │ ├── i18n/ Internationalization — 15 locales
│ │ │ └── uikit/ Reusable component kit system
│ │ └── server/
│ │ ├── api/ai/ Nitro API — streaming chat, generation, validation
│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot wrappers
│ │ ├── api/ai/ Nitro API — streaming chat, agent, generation, image search
│ │ ├── api/mcp/ MCP HTTP transport endpoints
│ │ └── utils/ Claude, OpenCode, Codex, Copilot, Gemini CLI wrappers
│ ├── desktop/ Electron desktop app
│ │ ├── main.ts Window, Nitro fork, native menu, auto-updater
│ │ ├── ipc-handlers.ts Native file dialogs, theme sync, prefs IPC
@ -289,7 +323,9 @@ openpencil/
│ ├── pen-codegen/ Code generators (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Figma .fig file parser and converter
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
│ └── pen-sdk/ Umbrella SDK (re-exports all packages)
│ ├── pen-sdk/ Umbrella SDK (re-exports all packages)
│ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading)
│ └── agent/ AI agent SDK (Vercel AI SDK, multi-provider, agent teams)
└── .githooks/ Pre-commit version sync from branch name
```
@ -348,6 +384,8 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
- [x] Multi-model capability profiles
- [x] Monorepo restructure with reusable packages
- [x] CLI tool (`op`) for terminal control
- [x] Built-in AI agent SDK with multi-provider support
- [x] i18n — 15 languages
- [ ] Collaborative editing
- [ ] Plugin system

View file

@ -31,6 +31,8 @@
<br />
> **Nota:** Existe outro projeto de código aberto com o mesmo nome — [OpenPencil](https://github.com/open-pencil/open-pencil), focado em design visual compatível com Figma com colaboração em tempo real. Este projeto foca em fluxos de trabalho AI-nativos de design para código.
## Por que OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Agente | Configuração |
| --- | --- |
| **Integrado (9+ provedores)** | Selecione entre presets de provedores com seletor de região — Anthropic, OpenAI, Google, DeepSeek e mais |
| **Claude Code** | Sem configuração — usa o Claude Agent SDK com OAuth local |
| **Codex CLI** | Conectar nas Configurações do Agente (`Cmd+,`) |
| **OpenCode** | Conectar nas Configurações do Agente (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Perfis de Capacidade de Modelo** — adapta automaticamente prompts, modo de thinking e timeouts por nível de modelo. Modelos de nível completo (Claude) recebem prompts completos; nível padrão (GPT-4o, Gemini, DeepSeek) desativam thinking; nível básico (MiniMax, Qwen, Llama, Mistral) recebem prompts simplificados de JSON aninhado para máxima confiabilidade.
**i18n** — Localização completa da interface em 15 idiomas: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**Servidor MCP**
- Servidor MCP integrado — instalação com um clique no Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Detecção automática de Node.js — se não instalado, recurso automático para transporte HTTP e início automático do servidor MCP HTTP
@ -219,6 +224,8 @@ cat design.dsl | op design - # Entrada por pipe via stdin
Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) ou `-` (ler de stdin). Funciona com o app desktop ou servidor web de desenvolvimento. Veja o [CLI README](./apps/cli/README.md) para referência completa de comandos.
**Habilidade LLM** — instale o plugin [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) para ensinar agentes IA (Claude Code, Cursor, Codex, Gemini CLI, etc.) a projetar com `op`.
## Funcionalidades
**Canvas e Desenho**
@ -248,13 +255,13 @@ Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) o
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Canvas** | CanvasKit/Skia (WASM, acelerado por GPU) |
| **Estado** | Zustand v5 |
| **Servidor** | Nitro |
| **Desktop** | Electron 35 |
| **CLI** | `op` — controle pelo terminal, DSL de design em lote, exportação de código |
| **IA** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Runtime** | Bun · Vite 7 |
| **Formato de arquivo** | `.op` — baseado em JSON, legível por humanos, compatível com Git |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Geradores de código (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Parser e conversor de arquivos .fig do Figma
│ ├── pen-renderer/ Renderizador CanvasKit/Skia independente
│ └── pen-sdk/ SDK guarda-chuva (re-exporta todos os pacotes)
│ ├── pen-sdk/ SDK guarda-chuva (re-exporta todos os pacotes)
│ ├── pen-ai-skills/ Engine de skills AI (carregamento de prompts por fases)
│ └── agent/ SDK de agente AI (Vercel AI SDK, multi-provedor, equipes de agentes)
└── .githooks/ Sincronização de versão no pre-commit a partir do nome da branch
```
@ -348,6 +357,8 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh
- [x] Perfis de capacidade multi-modelo
- [x] Reestruturação em monorepo com pacotes reutilizáveis
- [x] Ferramenta CLI (`op`) para controle pelo terminal
- [x] SDK de agente AI integrado com suporte multi-provedor
- [x] i18n — 15 idiomas
- [ ] Edição colaborativa
- [ ] Sistema de plugins

View file

@ -31,6 +31,8 @@
<br />
> **Примечание:** Существует другой проект с открытым исходным кодом с таким же названием — [OpenPencil](https://github.com/open-pencil/open-pencil), ориентированный на визуальный дизайн, совместимый с Figma, с совместной работой в реальном времени. Этот проект ориентирован на AI-нативные рабочие процессы «дизайн в код».
## Почему OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Агент | Настройка |
| --- | --- |
| **Встроенный (9+ провайдеров)** | Выбор из предустановленных провайдеров с переключателем региона — Anthropic, OpenAI, Google, DeepSeek и другие |
| **Claude Code** | Без настройки — использует Claude Agent SDK с локальным OAuth |
| **Codex CLI** | Подключить в настройках агента (`Cmd+,`) |
| **OpenCode** | Подключить в настройках агента (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Профили возможностей моделей** — автоматически адаптирует промпты, режим thinking и таймауты для каждого уровня моделей. Модели полного уровня (Claude) получают полные промпты; стандартного уровня (GPT-4o, Gemini, DeepSeek) — с отключённым thinking; базового уровня (MiniMax, Qwen, Llama, Mistral) — упрощённые промпты с вложенным JSON для максимальной надёжности.
**i18n** — Полная локализация интерфейса на 15 языках: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**MCP-сервер**
- Встроенный MCP-сервер — установка в один клик в Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- Автоопределение Node.js — если не установлен, автоматический переход на HTTP-транспорт и автозапуск MCP HTTP-сервера
@ -219,6 +224,8 @@ cat design.dsl | op design - # Передача через stdin
Поддерживает три метода ввода: строка, `@filepath` (чтение из файла) или `-` (чтение из stdin). Работает с десктопным приложением или веб-сервером разработки. Подробнее в [CLI README](./apps/cli/README.md).
**LLM-навык** — установите плагин [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill), чтобы научить ИИ-агентов (Claude Code, Cursor, Codex, Gemini CLI и др.) проектировать с помощью `op`.
## Возможности
**Холст и рисование**
@ -248,13 +255,13 @@ cat design.dsl | op design - # Передача через stdin
| | |
| --- | --- |
| **Фронтенд** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Фронтенд** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Холст** | CanvasKit/Skia (WASM, GPU-ускорение) |
| **Состояние** | Zustand v5 |
| **Сервер** | Nitro |
| **Десктоп** | Electron 35 |
| **CLI** | `op` — управление из терминала, пакетный DSL дизайна, экспорт кода |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Среда выполнения** | Bun · Vite 7 |
| **Формат файла** | `.op` — на основе JSON, удобочитаемый, дружественный к Git |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Генераторы кода (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Парсер и конвертер файлов Figma .fig
│ ├── pen-renderer/ Автономный рендерер CanvasKit/Skia
│ └── pen-sdk/ Зонтичный SDK (реэкспортирует все пакеты)
│ ├── pen-sdk/ Зонтичный SDK (реэкспортирует все пакеты)
│ ├── pen-ai-skills/ Движок AI-навыков (фазовая загрузка промптов)
│ └── agent/ SDK AI-агента (Vercel AI SDK, мультипровайдер, команды агентов)
└── .githooks/ Pre-commit синхронизация версий из имени ветки
```
@ -348,6 +357,8 @@ bun run cli:compile # Компиляция CLI в dist
- [x] Мультимодельные профили возможностей
- [x] Реструктуризация в монорепозиторий с переиспользуемыми пакетами
- [x] CLI-инструмент (`op`) для управления из терминала
- [x] Встроенный SDK AI-агента с поддержкой нескольких провайдеров
- [x] i18n — 15 языков
- [ ] Совместное редактирование
- [ ] Система плагинов

View file

@ -31,6 +31,8 @@
<br />
> **หมายเหตุ:** มีโปรเจกต์โอเพนซอร์สอีกโปรเจกต์หนึ่งที่ใช้ชื่อเดียวกัน — [OpenPencil](https://github.com/open-pencil/open-pencil) ซึ่งเน้นการออกแบบภาพที่เข้ากันได้กับ Figma พร้อมการทำงานร่วมกันแบบเรียลไทม์ โปรเจกต์นี้เน้นเวิร์กโฟลว์ AI-native สำหรับการแปลงดีไซน์เป็นโค้ด
## ทำไมต้อง OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Agent | วิธีตั้งค่า |
| --- | --- |
| **ในตัว (9+ ผู้ให้บริการ)** | เลือกจากพรีเซ็ตผู้ให้บริการพร้อมตัวสลับภูมิภาค — Anthropic, OpenAI, Google, DeepSeek และอื่น ๆ |
| **Claude Code** | ไม่ต้องตั้งค่า — ใช้ Claude Agent SDK พร้อม local OAuth |
| **Codex CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
| **OpenCode** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**โปรไฟล์ความสามารถของโมเดล** — ปรับ prompt, โหมด thinking และ timeout ตามระดับโมเดลโดยอัตโนมัติ โมเดลระดับเต็ม (Claude) ได้ prompt ครบถ้วน; โมเดลระดับมาตรฐาน (GPT-4o, Gemini, DeepSeek) ปิด thinking; โมเดลระดับพื้นฐาน (MiniMax, Qwen, Llama, Mistral) ได้ prompt แบบ nested-JSON ที่ย่อลงเพื่อความเสถียรสูงสุด
**i18n** — การแปลภาษาเต็มรูปแบบใน 15 ภาษา: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia
**MCP Server**
- MCP Server ในตัว — ติดตั้งได้ด้วยคลิกเดียวใน Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- ตรวจจับ Node.js อัตโนมัติ — หากไม่ได้ติดตั้ง จะสำรองไปใช้ HTTP transport และเริ่ม MCP HTTP server โดยอัตโนมัติ
@ -219,6 +224,8 @@ cat design.dsl | op design - # Pipe จาก stdin
รองรับ 3 วิธีการป้อนข้อมูล: สตริงแบบ inline, `@filepath` (อ่านจากไฟล์) หรือ `-` (อ่านจาก stdin) ทำงานร่วมกับแอปเดสก์ท็อปหรือ web dev server ดู [CLI README](./apps/cli/README.md) สำหรับคู่มือคำสั่งฉบับเต็ม
**LLM Skill** — ติดตั้งปลั๊กอิน [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) เพื่อสอน AI agent (Claude Code, Cursor, Codex, Gemini CLI ฯลฯ) ออกแบบด้วย `op`
## ฟีเจอร์
**Canvas และการวาด**
@ -248,13 +255,13 @@ cat design.dsl | op design - # Pipe จาก stdin
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
| **State** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
| **CLI** | `op` — ควบคุมจาก terminal, batch design DSL, ส่งออกโค้ด |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Runtime** | Bun · Vite 7 |
| **รูปแบบไฟล์** | `.op` — ใช้ JSON, อ่านได้โดยมนุษย์, Git-friendly |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Code generators (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Figma .fig file parser และ converter
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
│ └── pen-sdk/ Umbrella SDK (re-exports ทุก package)
│ ├── pen-sdk/ Umbrella SDK (re-exports ทุก package)
│ ├── pen-ai-skills/ AI prompt skill engine (โหลด prompt ตามเฟส)
│ └── agent/ AI Agent SDK (Vercel AI SDK, หลายผู้ให้บริการ, ทีม Agent)
└── .githooks/ Pre-commit version sync จาก branch name
```
@ -348,6 +357,8 @@ bun run cli:compile # คอมไพล์ CLI ไปยัง dist
- [x] โปรไฟล์ความสามารถหลายโมเดล
- [x] ปรับโครงสร้างเป็น monorepo พร้อม package ที่นำกลับมาใช้ใหม่ได้
- [x] เครื่องมือ CLI (`op`) ควบคุมจาก terminal
- [x] AI Agent SDK ในตัว รองรับหลายผู้ให้บริการ
- [x] i18n — 15 ภาษา
- [ ] การแก้ไขร่วมกัน
- [ ] ระบบปลั๊กอิน

View file

@ -31,6 +31,8 @@
<br />
> **Not:** Aynı ada sahip başka bir açık kaynak proje bulunmaktadır — [OpenPencil](https://github.com/open-pencil/open-pencil), Figma uyumlu görsel tasarım ve gerçek zamanlı iş birliğine odaklanmaktadır. Bu proje, AI-native tasarımdan koda iş akışlarına odaklanmaktadır.
## Neden OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Ajan | Kurulum |
| --- | --- |
| **Yerleşik (9+ sağlayıcı)** | Sağlayıcı ön ayarlarından seçin ve bölge değiştirin — Anthropic, OpenAI, Google, DeepSeek ve daha fazlası |
| **Claude Code** | Yapılandırma gerekmez — yerel OAuth ile Claude Agent SDK kullanır |
| **Codex CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
| **OpenCode** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Model Yetenek Profilleri** — promptları, düşünme modunu ve zaman aşımlarını model katmanına göre otomatik olarak uyarlar. Tam katman modeller (Claude) eksiksiz promptlar alır; standart katman (GPT-4o, Gemini, DeepSeek) düşünme modunu devre dışı bırakır; temel katman (MiniMax, Qwen, Llama, Mistral) maksimum güvenilirlik için basitleştirilmiş iç içe JSON promptları alır.
**i18n** — 15 dilde tam arayüz yerelleştirmesi: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**MCP Sunucusu**
- Yerleşik MCP sunucusu — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI'larına tek tıkla kurulum
- Otomatik Node.js algılama — kurulu değilse otomatik olarak HTTP aktarımına geçer ve MCP HTTP sunucusunu otomatik başlatır
@ -219,6 +224,8 @@ cat design.dsl | op design - # stdin'den pipe ile besle
Üç giriş yöntemini destekler: satır içi metin, `@filepath` (dosyadan oku) veya `-` (stdin'den oku). Masaüstü uygulama veya web geliştirme sunucusuyla çalışır. Tam komut referansı için [CLI README](./apps/cli/README.md) dosyasına bakın.
**LLM Becerisi** — [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) eklentisini kurarak AI ajanlarına (Claude Code, Cursor, Codex, Gemini CLI vb.) `op` ile tasarım yapmayı öğretin.
## Özellikler
**Kanvas ve Çizim**
@ -248,13 +255,13 @@ cat design.dsl | op design - # stdin'den pipe ile besle
| | |
| --- | --- |
| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Kanvas** | CanvasKit/Skia (WASM, GPU hızlandırmalı) |
| **Durum Yönetimi** | Zustand v5 |
| **Sunucu** | Nitro |
| **Masaüstü** | Electron 35 |
| **CLI** | `op` — terminal kontrolü, toplu tasarım DSL, kod dışa aktarımı |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Çalışma Ortamı** | Bun · Vite 7 |
| **Dosya Formatı** | `.op` — JSON tabanlı, insan tarafından okunabilir, Git dostu |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Kod oluşturucular (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Figma .fig dosya ayrıştırıcı ve dönüştürücü
│ ├── pen-renderer/ Bağımsız CanvasKit/Skia işleyici
│ └── pen-sdk/ Şemsiye SDK (tüm paketleri yeniden dışa aktarır)
│ ├── pen-sdk/ Şemsiye SDK (tüm paketleri yeniden dışa aktarır)
│ ├── pen-ai-skills/ AI prompt beceri motoru (aşamalı prompt yükleme)
│ └── agent/ AI ajan SDK'sı (Vercel AI SDK, çoklu sağlayıcı, ajan ekipleri)
└── .githooks/ Dal adından ön-commit sürüm eşitleme
```
@ -348,6 +357,8 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md]
- [x] Çoklu model yetenek profilleri
- [x] Yeniden kullanılabilir paketlerle monorepo yapılandırması
- [x] CLI aracı (`op`) terminal kontrolü
- [x] Çoklu sağlayıcı destekli yerleşik AI ajan SDK'sı
- [x] i18n — 15 dil
- [ ] Ortak düzenleme
- [ ] Eklenti sistemi

View file

@ -31,6 +31,8 @@
<br />
> **Lưu ý:** Có một dự án mã nguồn mở khác cùng tên — [OpenPencil](https://github.com/open-pencil/open-pencil), tập trung vào thiết kế trực quan tương thích Figma với cộng tác thời gian thực. Dự án này tập trung vào quy trình AI-native từ thiết kế sang mã.
## Tại sao chọn OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| Tác nhân | Cài đặt |
| --- | --- |
| **Tích hợp sẵn (9+ nhà cung cấp)** | Chọn từ các preset nhà cung cấp với bộ chuyển đổi khu vực — Anthropic, OpenAI, Google, DeepSeek và nhiều hơn |
| **Claude Code** | Không cần cấu hình — sử dụng Claude Agent SDK với OAuth cục bộ |
| **Codex CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
| **OpenCode** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**Hồ sơ Năng lực Mô hình** — tự động thích ứng prompt, chế độ thinking và thời gian chờ theo từng cấp mô hình. Mô hình cấp đầy đủ (Claude) nhận prompt hoàn chỉnh; cấp tiêu chuẩn (GPT-4o, Gemini, DeepSeek) tắt thinking; cấp cơ bản (MiniMax, Qwen, Llama, Mistral) nhận prompt JSON lồng nhau đơn giản hóa để đảm bảo độ tin cậy tối đa.
**i18n** — Bản địa hóa giao diện đầy đủ bằng 15 ngôn ngữ: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
**Máy chủ MCP**
- Máy chủ MCP tích hợp sẵn — cài đặt một cú nhấp vào Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- Tự động phát hiện Node.js — nếu chưa cài đặt, tự động chuyển sang HTTP transport và khởi động MCP HTTP server
@ -219,6 +224,8 @@ cat design.dsl | op design - # Pipe từ stdin
Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc từ tệp), hoặc `-` (đọc từ stdin). Hoạt động với ứng dụng desktop hoặc web dev server. Xem [CLI README](./apps/cli/README.md) để biết đầy đủ các lệnh.
**LLM Skill** — cài đặt plugin [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) để dạy AI agent (Claude Code, Cursor, Codex, Gemini CLI, v.v.) thiết kế bằng `op`.
## Tính năng
**Canvas và Vẽ**
@ -248,13 +255,13 @@ Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **Canvas** | CanvasKit/Skia (WASM, tăng tốc GPU) |
| **Trạng thái** | Zustand v5 |
| **Máy chủ** | Nitro |
| **Desktop** | Electron 35 |
| **CLI** | `op` — điều khiển từ terminal, batch design DSL, xuất mã |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **Runtime** | Bun · Vite 7 |
| **Định dạng tệp** | `.op` — dựa trên JSON, dễ đọc, thân thiện với Git |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ Bộ tạo mã (React, HTML, Vue, Flutter, ...)
│ ├── pen-figma/ Trình phân tích và chuyển đổi tệp Figma .fig
│ ├── pen-renderer/ Bộ dựng hình CanvasKit/Skia độc lập
│ └── pen-sdk/ SDK tổng hợp (tái xuất tất cả các gói)
│ ├── pen-sdk/ SDK tổng hợp (tái xuất tất cả các gói)
│ ├── pen-ai-skills/ Engine kỹ năng AI prompt (tải prompt theo giai đoạn)
│ └── agent/ SDK tác nhân AI (Vercel AI SDK, đa nhà cung cấp, đội tác nhân)
└── .githooks/ Pre-commit đồng bộ phiên bản từ tên nhánh
```
@ -348,6 +357,8 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v
- [x] Hồ sơ năng lực đa mô hình
- [x] Tái cấu trúc monorepo với các gói tái sử dụng
- [x] Công cụ CLI (`op`) điều khiển từ terminal
- [x] SDK tác nhân AI tích hợp sẵn với hỗ trợ đa nhà cung cấp
- [x] i18n — 15 ngôn ngữ
- [ ] Chỉnh sửa cộng tác
- [ ] Hệ thống plugin

View file

@ -31,6 +31,8 @@
<br />
> **注意:** 另有一個同名的開源專案 — [OpenPencil](https://github.com/open-pencil/open-pencil),專注於相容 Figma 的視覺設計與即時協作。本專案專注於 AI 原生的設計轉程式碼工作流。
## 為什麼選擇 OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| 智能體 | 設定方式 |
| --- | --- |
| **內建9+ 提供商)** | 從提供商預設中選擇並切換區域 — Anthropic、OpenAI、Google、DeepSeek 等 |
| **Claude Code** | 無需設定 — 使用 Claude Agent SDK 本地 OAuth |
| **Codex CLI** | 在 Agent 設定中連接(`Cmd+,` |
| **OpenCode** | 在 Agent 設定中連接(`Cmd+,` |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**模型能力設定檔** — 自動依據模型層級調整提示詞、思考模式和逾時設定。完整層級模型Claude獲得完整提示詞標準層級GPT-4o、Gemini、DeepSeek停用思考模式基礎層級MiniMax、Qwen、Llama、Mistral獲得精簡巢狀 JSON 提示詞,確保最大可靠性。
**國際化** — 完整介面本地化,支援 15 種語言English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。
**MCP 伺服器**
- 內建 MCP 伺服器 — 一鍵安裝至 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- 自動偵測 Node.js — 若未安裝則自動回退到 HTTP 傳輸模式並啟動 MCP HTTP 伺服器
@ -219,6 +224,8 @@ cat design.dsl | op design - # 從 stdin 管道輸入
支援三種輸入方式:內嵌字串、`@filepath`(從檔案讀取)、`-`(從 stdin 讀取)。可搭配桌面應用程式或 Web 開發伺服器使用。完整命令參考請查閱 [CLI README](./apps/cli/README.md)。
**LLM 技能** — 安裝 [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) 外掛,教 AI 智慧體Claude Code、Cursor、Codex、Gemini CLI 等)使用 `op` 進行設計。
## 功能特色
**畫布與繪圖**
@ -248,13 +255,13 @@ cat design.dsl | op design - # 從 stdin 管道輸入
| | |
| --- | --- |
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **畫布** | CanvasKit/SkiaWASM、GPU 加速) |
| **狀態管理** | Zustand v5 |
| **伺服器** | Nitro |
| **桌面端** | Electron 35 |
| **CLI** | `op` — 終端機控制、批次設計 DSL、程式碼匯出 |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **執行環境** | Bun · Vite 7 |
| **檔案格式** | `.op` — 基於 JSON人類可讀對 Git 友好 |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ 程式碼生成器React、HTML、Vue、Flutter...
│ ├── pen-figma/ Figma .fig 檔案解析器與轉換器
│ ├── pen-renderer/ 獨立 CanvasKit/Skia 渲染器
│ └── pen-sdk/ 整合 SDK重新匯出所有套件
│ ├── pen-sdk/ 整合 SDK重新匯出所有套件
│ ├── pen-ai-skills/ AI 提示詞技能引擎(分階段 prompt 載入)
│ └── agent/ AI Agent SDKVercel AI SDK、多提供商、Agent 團隊)
└── .githooks/ Pre-commit 版本號同步(從分支名稱)
```
@ -348,6 +357,8 @@ bun run cli:compile # 編譯 CLI 到 dist
- [x] 多模型能力設定檔
- [x] Monorepo 重構,支援可重複使用套件
- [x] CLI 工具(`op`)終端控制
- [x] 內建 AI Agent SDK支援多提供商
- [x] 國際化 — 15 種語言
- [ ] 協同編輯
- [ ] 外掛程式系統

View file

@ -31,6 +31,8 @@
<br />
> **注意:** 另有一个同名的开源项目 — [OpenPencil](https://github.com/open-pencil/open-pencil),专注于兼容 Figma 的可视化设计与实时协作。本项目专注于 AI 原生的设计转代码工作流。
## 为什么选择 OpenPencil
<table>
@ -180,6 +182,7 @@ docker build --target full -t openpencil-full .
| 智能体 | 配置方式 |
| --- | --- |
| **内置9+ 提供商)** | 从提供商预设中选择并切换区域 — Anthropic、OpenAI、Google、DeepSeek 等 |
| **Claude Code** | 无需配置 — 使用 Claude Agent SDK 本地 OAuth |
| **Codex CLI** | 在 Agent 设置中连接(`Cmd+,` |
| **OpenCode** | 在 Agent 设置中连接(`Cmd+,` |
@ -188,6 +191,8 @@ docker build --target full -t openpencil-full .
**模型能力配置** — 自动根据模型层级适配提示词、思考模式和超时时间。完整层级模型Claude获得完整提示词标准层级模型GPT-4o、Gemini、DeepSeek关闭思考模式基础层级模型MiniMax、Qwen、Llama、Mistral使用简化的嵌套 JSON 提示词以确保最大可靠性。
**国际化** — 完整界面本地化,支持 15 种语言English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。
**MCP 服务器**
- 内置 MCP 服务器 — 一键安装到 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- 自动检测 Node.js — 若未安装则自动回退到 HTTP 传输模式并启动 MCP HTTP 服务器
@ -219,6 +224,8 @@ cat design.dsl | op design - # 从 stdin 管道输入
支持三种输入方式:内联字符串、`@filepath`(从文件读取)、`-`(从 stdin 读取)。可搭配桌面应用或 Web 开发服务器使用。完整命令参考请查阅 [CLI README](./apps/cli/README.md)。
**LLM 技能** — 安装 [OpenPencil Skill](https://github.com/ZSeven-W/openpencil-skill) 插件,教 AI 智能体Claude Code、Cursor、Codex、Gemini CLI 等)使用 `op` 进行设计。
## 功能特性
**画布与绘图**
@ -248,13 +255,13 @@ cat design.dsl | op design - # 从 stdin 管道输入
| | |
| --- | --- |
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
| **画布** | CanvasKit/SkiaWASM, GPU 加速) |
| **状态管理** | Zustand v5 |
| **服务器** | Nitro |
| **桌面端** | Electron 35 |
| **CLI** | `op` — 终端控制、批量设计 DSL、代码导出 |
| **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
| **运行时** | Bun · Vite 7 |
| **文件格式** | `.op` — 基于 JSON人类可读对 Git 友好 |
@ -289,7 +296,9 @@ openpencil/
│ ├── pen-codegen/ 代码生成器React、HTML、Vue、Flutter 等)
│ ├── pen-figma/ Figma .fig 文件解析与转换
│ ├── pen-renderer/ 独立 CanvasKit/Skia 渲染器
│ └── pen-sdk/ 聚合 SDK重新导出所有包
│ ├── pen-sdk/ 聚合 SDK重新导出所有包
│ ├── pen-ai-skills/ AI 提示词技能引擎(分阶段 prompt 加载)
│ └── agent/ AI Agent SDKVercel AI SDK、多提供商、Agent 团队)
└── .githooks/ 预提交钩子:从分支名同步版本号
```
@ -348,6 +357,8 @@ bun run cli:compile # 编译 CLI 到 dist
- [x] 多模型能力配置
- [x] Monorepo 重构与可复用包
- [x] CLI 工具(`op`)终端控制
- [x] 内置 AI Agent SDK支持多提供商
- [x] 国际化 — 15 种语言
- [ ] 协同编辑
- [ ] 插件系统

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/openpencil",
"version": "0.5.2",
"version": "0.6.0",
"description": "CLI for OpenPencil — control the design tool from your terminal",
"author": {
"name": "ZSeven-W",

View file

@ -1,12 +1,33 @@
/** Port file discovery and app health check. */
import { readFile } from 'node:fs/promises'
import { readFile, unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { homedir } from 'node:os'
const PORT_FILE_DIR = '.openpencil'
const PORT_FILE_NAME = '.port'
const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR, PORT_FILE_NAME)
const APP_BASE_URLS = ['http://127.0.0.1', 'http://localhost']
async function getReachableAppUrl(port: number): Promise<string | null> {
for (const baseUrl of APP_BASE_URLS) {
const url = `${baseUrl}:${port}/api/mcp/server`
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(500),
})
if (res.ok) return `${baseUrl}:${port}`
} catch {
// App may still be starting, or the port file may be stale.
}
if (attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
}
return null
}
function isPidAlive(pid: number): boolean {
try {
@ -33,8 +54,19 @@ export async function getAppInfo(): Promise<AppInfo | null> {
pid: number
timestamp: number
}
if (!isPidAlive(pid)) return null
return { port, pid, timestamp, url: `http://127.0.0.1:${port}` }
const url = await getReachableAppUrl(port)
if (url) {
return { port, pid, timestamp, url }
}
if (!isPidAlive(pid)) {
try {
await unlink(PORT_FILE_PATH)
} catch {
// Ignore stale port file cleanup failures.
}
return null
}
return null
} catch {
return null
}

View file

@ -136,6 +136,7 @@ async function main(): Promise<void> {
page: flags.page as string | undefined,
}
switch (command) {
// --- App ---
case 'start': {

View file

@ -2,7 +2,7 @@
import { spawn, fork, execSync } from 'node:child_process'
import { createServer } from 'node:net'
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'
import { writeFile, unlink, mkdir } from 'node:fs/promises'
import { join, dirname } from 'node:path'
import { homedir } from 'node:os'
import { existsSync } from 'node:fs'
@ -31,13 +31,8 @@ function getFreePort(): Promise<number> {
async function waitForPortFile(timeoutMs = 15_000): Promise<{ port: number; pid: number }> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const raw = await readFile(PORT_FILE_PATH, 'utf-8')
const data = JSON.parse(raw)
if (data.port && data.pid) return data
} catch {
// not ready yet
}
const info = await getAppInfo()
if (info) return { port: info.port, pid: info.pid }
await new Promise((r) => setTimeout(r, 300))
}
throw new Error('Timeout waiting for OpenPencil to start')

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/desktop",
"version": "0.5.2",
"version": "0.6.0",
"private": true,
"type": "module"
}

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/web",
"version": "0.5.2",
"version": "0.6.0",
"private": true,
"type": "module",
"dependencies": {
@ -9,6 +9,8 @@
"@zseven-w/pen-codegen": "workspace:*",
"@zseven-w/pen-figma": "workspace:*",
"@zseven-w/pen-renderer": "workspace:*",
"@zseven-w/pen-ai-skills": "workspace:*"
"@zseven-w/pen-ai-skills": "workspace:*",
"@zseven-w/agent": "workspace:*",
"zod": "^3.24"
}
}

View file

@ -0,0 +1,235 @@
import { defineEventHandler, readBody, setResponseHeaders, getQuery, createError } from 'h3'
import {
createAgent,
createTeam,
createAnthropicProvider,
createOpenAICompatProvider,
createToolRegistry,
encodeAgentEvent,
} from '@zseven-w/agent'
import type { AuthLevel } from '@zseven-w/agent'
import { jsonSchema } from '@zseven-w/agent'
import { agentSessions } from '../../utils/agent-sessions'
interface ToolDef {
name: string
description: string
level: AuthLevel
/** JSON Schema from client — single source of truth, no server-side duplication */
parameters?: Record<string, unknown>
}
interface MemberDef {
id: string
providerType: 'anthropic' | 'openai-compat'
apiKey: string
model: string
baseURL?: string
systemPrompt?: string
}
interface AgentBody {
sessionId: string
messages: Array<{ role: string; content: unknown }>
systemPrompt: string
providerType: 'anthropic' | 'openai-compat'
apiKey: string
model: string
baseURL?: string
toolDefs: ToolDef[]
maxTurns?: number
maxOutputTokens?: number
members?: MemberDef[]
}
function toModelMessages(raw: Array<{ role: string; content: unknown }>) {
return raw
.filter((m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content as string,
}))
}
/**
* Unified agent endpoint. Routes by `?action=` query param:
* POST /api/ai/agent Start agent loop (SSE stream)
* POST /api/ai/agent?action=result Resolve a pending tool call
* POST /api/ai/agent?action=abort Abort an agent session
*/
export default defineEventHandler(async (event) => {
const { action } = getQuery(event) as { action?: string }
// ── Tool result callback ────────────────────────────────────
if (action === 'result') {
const body = await readBody<{ sessionId: string; toolCallId: string; result: any }>(event)
if (!body?.sessionId || !body.toolCallId || !body.result) {
throw createError({ statusCode: 400, message: 'Missing: sessionId, toolCallId, result' })
}
const session = agentSessions.get(body.sessionId)
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' })
}
session.agent.resolveToolResult(body.toolCallId, body.result)
session.lastActivity = Date.now()
return { ok: true }
}
// ── Abort ───────────────────────────────────────────────────
if (action === 'abort') {
const body = await readBody<{ sessionId?: string }>(event)
const sid = body?.sessionId
if (sid) {
const session = agentSessions.get(sid)
if (session) {
session.abortController.abort()
agentSessions.delete(sid)
}
}
return { ok: true }
}
// ── Start agent loop (SSE stream) ──────────────────────────
const body = await readBody<AgentBody>(event)
if (!body?.sessionId || !body.messages || !body.systemPrompt || !body.providerType || !body.apiKey || !body.model) {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing required fields: sessionId, messages, systemPrompt, providerType, apiKey, model' }
}
const provider = body.providerType === 'anthropic'
? createAnthropicProvider({ apiKey: body.apiKey, model: body.model, baseURL: body.baseURL })
: createOpenAICompatProvider({ apiKey: body.apiKey, model: body.model, baseURL: body.baseURL })
const tools = createToolRegistry()
for (const def of body.toolDefs ?? []) {
// Use client-provided JSON Schema (single source of truth)
// Strip $schema field that strict APIs (MiniMax, StepFun) reject
const params = def.parameters ? { ...def.parameters } : { type: 'object' }
delete (params as any).$schema
tools.register({
name: def.name,
description: def.description,
level: def.level,
schema: jsonSchema(params as any),
})
}
const abortController = new AbortController()
// Create agent or team based on whether members are provided
let agentOrTeam: { run: (msgs: any) => AsyncGenerator<any>; resolveToolResult: (id: string, result: any) => void }
if (body.members?.length) {
// Team mode — create member agents with scoped tools
// Designer only gets generate_design + snapshot_layout (read-only check).
// Giving all tools causes wasteful batch_get calls after generation.
const DESIGNER_TOOLS = new Set(['generate_design', 'snapshot_layout'])
const members = body.members.map(m => {
const memberProvider = m.providerType === 'anthropic'
? createAnthropicProvider({ apiKey: m.apiKey, model: m.model, baseURL: m.baseURL })
: createOpenAICompatProvider({ apiKey: m.apiKey, model: m.model, baseURL: m.baseURL })
const memberTools = createToolRegistry()
const allowedTools = m.id === 'designer' ? DESIGNER_TOOLS : null
for (const def of body.toolDefs ?? []) {
if (allowedTools && !allowedTools.has(def.name)) continue
const params = def.parameters ? { ...def.parameters } : { type: 'object' }
delete (params as any).$schema
memberTools.register({
name: def.name,
description: def.description,
level: def.level,
schema: jsonSchema(params as any),
})
}
return {
id: m.id,
provider: memberProvider,
tools: memberTools,
systemPrompt: m.systemPrompt || `You are a ${m.id} specialist.`,
turnTimeout: 5 * 60_000, // 5 minutes — design generation is slow
}
})
// Remove generate_design from lead tools — force delegation to designer
const leadTools = createToolRegistry()
for (const def of body.toolDefs ?? []) {
if (def.name === 'generate_design') continue // designer-only
const params = def.parameters ? { ...def.parameters } : { type: 'object' }
delete (params as any).$schema
leadTools.register({
name: def.name,
description: def.description,
level: def.level,
schema: jsonSchema(params as any),
})
}
const team = createTeam({
lead: { provider, tools: leadTools, systemPrompt: body.systemPrompt, maxTurns: body.maxTurns ?? 20 },
members,
})
agentOrTeam = { run: (msgs) => team.run(msgs), resolveToolResult: (id, result) => team.resolveToolResult(id, result) }
} else {
const agent = createAgent({
provider,
tools,
systemPrompt: body.systemPrompt,
maxTurns: body.maxTurns ?? 20,
maxOutputTokens: body.maxOutputTokens,
turnTimeout: 5 * 60_000,
abortSignal: abortController.signal,
})
agentOrTeam = agent
}
agentSessions.set(body.sessionId, { agent: agentOrTeam as any, abortController, createdAt: Date.now(), lastActivity: Date.now() })
setResponseHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const encoder = new TextEncoder()
let stream: ReadableStream
try {
stream = new ReadableStream({
async start(controller) {
const pingTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch { /* stream already closed */ }
}, 5_000)
try {
for await (const agentEvent of agentOrTeam.run(toModelMessages(body.messages))) {
const session = agentSessions.get(body.sessionId)
if (session) session.lastActivity = Date.now()
controller.enqueue(encoder.encode(encodeAgentEvent(agentEvent)))
}
} catch (err: any) {
try {
controller.enqueue(encoder.encode(encodeAgentEvent({
type: 'error',
message: err?.message ?? String(err),
fatal: true,
})))
} catch { /* ignore */ }
} finally {
clearInterval(pingTimer)
agentSessions.delete(body.sessionId)
try { controller.close() } catch { /* ignore */ }
}
},
})
} catch (err) {
// Stream construction failed — clean up session
agentSessions.delete(body.sessionId)
throw err
}
return new Response(stream)
})

View file

@ -31,10 +31,16 @@ interface ChatBody {
system: string
messages: Array<{ role: 'user' | 'assistant'; content: string; attachments?: ChatAttachmentWire[] }>
model?: string
provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot' | 'gemini'
provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot' | 'gemini' | 'builtin'
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
thinkingBudgetTokens?: number
effort?: 'low' | 'medium' | 'high' | 'max'
/** For builtin provider: direct API key (not CLI-based) */
builtinApiKey?: string
/** For builtin provider: base URL for OpenAI-compatible endpoints */
builtinBaseURL?: string
/** For builtin provider: 'anthropic' or 'openai-compat' */
builtinType?: 'anthropic' | 'openai-compat'
}
async function readDebugTail(path?: string, maxLines = 40): Promise<string[] | undefined> {
@ -135,7 +141,7 @@ export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing model. Model fallback is disabled.' }
}
if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot' && body.provider !== 'gemini') {
if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot' && body.provider !== 'gemini' && body.provider !== 'builtin') {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }
}
@ -146,6 +152,7 @@ export default defineEventHandler(async (event) => {
Connection: 'keep-alive',
})
if (body.provider === 'builtin') return streamViaBuiltin(body)
if (body.provider === 'anthropic') return streamViaAgentSDK(body, body.model)
if (body.provider === 'opencode') return streamViaOpenCode(body, body.model)
if (body.provider === 'copilot') return streamViaCopilot(body, body.model)
@ -153,8 +160,9 @@ export default defineEventHandler(async (event) => {
return streamViaCodex(body, body.model)
})
// Keep-alive ping interval (ms) — prevents client timeout while waiting for API TTFT
const KEEPALIVE_INTERVAL_MS = 15_000
// Keep-alive ping interval (ms) — must be shorter than Bun's 10s idle timeout
// to prevent "socket connection was closed unexpectedly" errors
const KEEPALIVE_INTERVAL_MS = 5_000
function getAgentThinkingConfig(body: ChatBody):
| { type: 'adaptive' | 'disabled' }
| { type: 'enabled'; budgetTokens?: number }
@ -846,8 +854,10 @@ function streamViaCopilot(body: ChatBody, model?: string) {
try {
const { CopilotClient, approveAll } = await import('@github/copilot-sdk')
// Use standalone copilot binary to avoid Bun's node:sqlite issue
const { resolveCopilotCli } = await import('../../utils/copilot-client')
const cliPath = resolveCopilotCli()
const { resolveCopilotCli, resolveCliPathForSdk } = await import('../../utils/copilot-client')
const rawCliPath = resolveCopilotCli()
// On Windows, .cmd wrappers cause "spawn EINVAL" — resolve to .js entry point
const cliPath = rawCliPath ? resolveCliPathForSdk(rawCliPath) : undefined
const client = new CopilotClient({
autoStart: true,
...(cliPath ? { cliPath } : {}),
@ -902,3 +912,67 @@ function streamViaCopilot(body: ChatBody, model?: string) {
return new Response(stream)
}
/**
* Stream via builtin provider direct API key, no CLI tool needed.
* Uses Vercel AI SDK's streamText with Anthropic or OpenAI-compatible providers.
*/
function streamViaBuiltin(body: ChatBody) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
const pingTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
} catch { /* stream already closed */ }
}, KEEPALIVE_INTERVAL_MS)
try {
const { streamText } = await import('@zseven-w/agent')
const apiKey = body.builtinApiKey
const model = body.model?.trim()
if (!apiKey || !model) throw new Error('Builtin provider requires apiKey and model')
const { createAnthropicProvider, createOpenAICompatProvider } = await import('@zseven-w/agent')
const provider = body.builtinType === 'anthropic'
? createAnthropicProvider({ apiKey, model, baseURL: body.builtinBaseURL })
: createOpenAICompatProvider({ apiKey, model, baseURL: body.builtinBaseURL })
const llmModel = provider.model
const messages = body.messages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}))
const response = streamText({
model: llmModel,
system: body.system,
messages,
})
for await (const part of response.fullStream) {
if (part.type === 'text-delta' && part.text) {
clearInterval(pingTimer)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'text', content: part.text })}\n\n`),
)
} else if (part.type === 'reasoning-delta' && (part as any).text) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'thinking', content: (part as any).text })}\n\n`),
)
}
}
} catch (error) {
const content = error instanceof Error ? error.message : 'Unknown error'
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
} finally {
clearInterval(pingTimer)
controller.close()
}
},
})
return new Response(stream)
}

View file

@ -250,7 +250,7 @@ async function buildCodexConnectionInfo(): Promise<{ connectionInfo: string; hin
const { readFile } = await import('node:fs/promises')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const hp = configPath('~/.codex/config.json', '%USERPROFILE%\\.codex\\config.json')
const hp = configPath('~/.codex/config.toml', '%USERPROFILE%\\.codex\\config.toml')
if (process.env.OPENAI_API_KEY) {
const key = process.env.OPENAI_API_KEY
@ -558,7 +558,7 @@ async function connectOpenCode(): Promise<ConnectResult> {
const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client')
serverLog.info('[connect-agent] creating opencode client...')
const { client, server } = await getOpencodeClient()
const { client, server } = await getOpencodeClient(binaryPath)
serverLog.info('[connect-agent] fetching opencode providers...')
const { data, error } = await client.config.providers()
@ -608,13 +608,16 @@ async function connectOpenCode(): Promise<ConnectResult> {
async function connectCopilot(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Copilot...')
// Use standalone copilot binary to avoid Bun's node:sqlite issue
const { resolveCopilotCli } = await import('../../utils/copilot-client')
const cliPath = resolveCopilotCli()
serverLog.info(`[connect-agent] resolved copilot path: ${cliPath ?? 'NOT FOUND'}`)
if (!cliPath) {
const { resolveCopilotCli, resolveCliPathForSdk } = await import('../../utils/copilot-client')
const rawCliPath = resolveCopilotCli()
serverLog.info(`[connect-agent] resolved copilot path: ${rawCliPath ?? 'NOT FOUND'}`)
if (!rawCliPath) {
return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found' }
}
// On Windows, .cmd wrappers cause "spawn EINVAL" — resolve to .js entry point
const cliPath = resolveCliPathForSdk(rawCliPath)
try {
const { CopilotClient } = await import('@github/copilot-sdk')
const client = new CopilotClient({ autoStart: true, cliPath })

View file

@ -2,8 +2,8 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { homedir } from 'node:os'
import { join, resolve, dirname } from 'node:path'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { existsSync, readdirSync, statSync } from 'node:fs'
import { execSync, execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
// ESM-compatible __dirname polyfill
@ -28,6 +28,7 @@ interface InstallResult {
}
const MCP_SERVER_NAME = 'openpencil'
const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml')
/**
* Resolve the absolute path to the compiled MCP server.
@ -66,41 +67,91 @@ function resolveMcpServerPath(): string {
* Caches the result for the lifetime of the process.
*/
let _nodeAvailable: boolean | null = null
let _nodeCommand: string | null = null
function nodeCandidates(): string[] {
if (process.platform === 'win32') {
return [
'node.exe',
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe'),
join(process.env.LOCALAPPDATA ?? '', 'fnm_multishells', '**', 'node.exe'),
join(homedir(), '.nvm', 'current', 'bin', 'node.exe'),
]
}
const candidates = [
'node',
'/usr/local/bin/node',
'/usr/bin/node',
'/opt/homebrew/bin/node',
]
// NVM: resolve the active node version via the symlink or by reading
// .nvm/alias/default, then constructing the versioned bin path.
// The old path (`.nvm/versions/node`) is a directory, not a binary —
// existsSync would return true but executing it gives "Permission denied".
const nvmDir = join(homedir(), '.nvm')
const nvmCurrent = join(nvmDir, 'current', 'bin', 'node')
candidates.push(nvmCurrent)
// Fallback: if NVM_DIR/current doesn't exist, find the highest installed version
if (!existsSync(nvmCurrent)) {
const versionsDir = join(nvmDir, 'versions', 'node')
if (existsSync(versionsDir)) {
try {
const versions = readdirSync(versionsDir)
.filter((d) => d.startsWith('v'))
.sort()
if (versions.length > 0) {
candidates.push(join(versionsDir, versions[versions.length - 1], 'bin', 'node'))
}
} catch { /* ignore */ }
}
}
return candidates
}
function isNodeAvailable(): boolean {
if (_nodeAvailable !== null) return _nodeAvailable
// Try PATH first
try {
execSync('node --version', { stdio: 'ignore', timeout: 5000 })
const whichCmd = process.platform === 'win32'
? 'where node 2>nul'
: 'which node 2>/dev/null'
const resolved = execSync(whichCmd, { encoding: 'utf-8', timeout: 5000 })
.trim()
.split(/\r?\n/)[0]
?.trim()
_nodeCommand = resolved || 'node'
_nodeAvailable = true
return true
} catch { /* not on PATH */ }
// Check common absolute paths (macOS/Linux + Windows)
const candidates = process.platform === 'win32'
? [
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe'),
join(process.env.LOCALAPPDATA ?? '', 'fnm_multishells', '**', 'node.exe'),
join(homedir(), '.nvm', 'current', 'bin', 'node.exe'),
]
: [
'/usr/local/bin/node',
'/usr/bin/node',
join(homedir(), '.nvm', 'versions', 'node'), // nvm directory
'/opt/homebrew/bin/node',
]
for (const p of candidates) {
if (existsSync(p)) {
_nodeAvailable = true
return true
}
// Check common absolute paths (macOS/Linux + Windows).
// Must verify the path is a file, not a directory — existsSync returns
// true for directories, which caused the NVM versions dir to be treated
// as a node binary.
for (const p of nodeCandidates().slice(1)) {
try {
if (existsSync(p) && statSync(p).isFile()) {
_nodeCommand = p
_nodeAvailable = true
return true
}
} catch { /* ignore stat errors */ }
}
_nodeAvailable = false
return false
}
function resolveNodeCommand(): string {
if (isNodeAvailable()) return _nodeCommand ?? 'node'
throw new Error('Node.js not found')
}
function buildMcpServerEntry(
serverPath: string,
transportMode: 'stdio' | 'http' | 'both' = 'stdio',
@ -169,11 +220,6 @@ const CLI_CONFIGS: Record<string, CliConfigDef> = {
read: readJsonConfig,
write: writeJsonConfig,
},
'codex-cli': {
configPath: () => join(homedir(), '.codex', 'config.json'),
read: readJsonConfig,
write: writeJsonConfig,
},
'gemini-cli': {
configPath: () => join(homedir(), '.gemini', 'settings.json'),
read: readJsonConfig,
@ -216,6 +262,57 @@ async function writeJsonConfig(
await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
}
function codexBinary(): string {
return process.platform === 'win32' ? 'codex.cmd' : 'codex'
}
async function installCodexMcp(
transportMode?: 'stdio' | 'http' | 'both',
httpPort?: number,
): Promise<{ configPath: string; fallbackHttp: boolean }> {
const serverPath = resolveMcpServerPath()
const port = httpPort ?? MCP_DEFAULT_PORT
const useHttp = transportMode === 'http' || !isNodeAvailable()
try {
uninstallCodexMcp()
} catch {
// Ignore missing-entry cleanup failures; install below is the real operation.
}
if (useHttp) {
try {
const { startMcpHttpServer } = await import('../../utils/mcp-server-manager')
startMcpHttpServer(port)
} catch {
// Non-fatal: server may already be running or will be started manually
}
execFileSync(
codexBinary(),
['mcp', 'add', MCP_SERVER_NAME, '--url', `http://127.0.0.1:${port}/mcp`],
{ encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' },
)
return { configPath: CODEX_CONFIG_PATH, fallbackHttp: true }
}
execFileSync(
codexBinary(),
['mcp', 'add', MCP_SERVER_NAME, '--', resolveNodeCommand(), serverPath],
{ encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' },
)
return { configPath: CODEX_CONFIG_PATH, fallbackHttp: false }
}
function uninstallCodexMcp(): { configPath: string } {
execFileSync(
codexBinary(),
['mcp', 'remove', MCP_SERVER_NAME],
{ encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' },
)
return { configPath: CODEX_CONFIG_PATH }
}
/**
* POST /api/ai/mcp-install
* Install or uninstall the openpencil MCP server into a CLI tool's config.
@ -228,6 +325,25 @@ export default defineEventHandler(async (event) => {
return { success: false, error: 'Missing tool or action field' } satisfies InstallResult
}
// Codex CLI uses its own `codex mcp add/remove` commands (writes ~/.codex/config.toml)
if (body.tool === 'codex-cli') {
try {
const result = body.action === 'uninstall'
? uninstallCodexMcp()
: await installCodexMcp(body.transportMode, body.httpPort)
return {
success: true,
configPath: result.configPath,
...('fallbackHttp' in result && result.fallbackHttp ? { fallbackHttp: true } : {}),
} satisfies InstallResult
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
} satisfies InstallResult
}
}
const cliConfig = CLI_CONFIGS[body.tool]
if (!cliConfig) {
return { success: false, error: `Unknown CLI tool: ${body.tool}` } satisfies InstallResult

View file

@ -0,0 +1,63 @@
import { defineEventHandler, readBody } from 'h3'
interface ProviderModelsBody {
baseURL: string
apiKey?: string
}
interface ModelEntry {
id: string
name: string
}
/**
* POST /api/ai/provider-models
* Proxies model list requests to external providers to avoid CORS issues.
* Body: { baseURL: string, apiKey?: string }
* Returns: { models: Array<{ id: string, name: string }> }
*/
export default defineEventHandler(async (event) => {
const body = await readBody<ProviderModelsBody>(event)
if (!body?.baseURL) {
return { models: [], error: 'baseURL is required' }
}
const url = body.baseURL.replace(/\/+$/, '') + '/models'
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (body.apiKey) {
headers.Authorization = `Bearer ${body.apiKey}`
}
try {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) })
if (!res.ok) {
const text = await res.text().catch(() => '')
return { models: [], error: `Provider returned ${res.status}: ${text.slice(0, 200)}` }
}
const json = await res.json() as Record<string, unknown>
// Handle different response formats: { data: [...] } (OpenAI), { models: [...] }, or [...]
const rawModels = Array.isArray(json.data) ? json.data
: Array.isArray(json.models) ? json.models
: Array.isArray(json) ? json
: null
if (!rawModels) {
return { models: [], error: 'Unexpected response format (no model array found)' }
}
const models: ModelEntry[] = (rawModels as Array<Record<string, unknown>>)
.filter((m) => m.id)
.map((m) => ({
id: String(m.id),
name: (typeof m.name === 'string' ? m.name : '') || String(m.id),
}))
.sort((a, b) => a.name.localeCompare(b.name))
return { models }
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return { models: [], error: message }
}
})

View file

@ -0,0 +1,8 @@
import { defineEventHandler } from 'h3'
import { clearSyncState } from '../../utils/mcp-sync-state'
/** POST /api/mcp/sync-reset — Clears stale sync cache on page load / file open. */
export default defineEventHandler(() => {
clearSyncState()
return { ok: true }
})

View file

@ -7,6 +7,8 @@ export type ServerOptions = {
signal?: AbortSignal
timeout?: number
config?: Config
/** Absolute path to the opencode binary (avoids PATH lookup issues in Electron). */
binaryPath?: string
}
export type TuiOptions = {
@ -31,7 +33,8 @@ export async function createOpencodeServer(options?: ServerOptions) {
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
const proc = spawn(`opencode`, args, {
const cmd = options.binaryPath ?? 'opencode'
const proc = spawn(cmd, args, {
shell: process.platform === 'win32',
signal: options.signal,
env: {

View file

@ -7,6 +7,8 @@ export type ServerOptions = {
signal?: AbortSignal
timeout?: number
config?: Config
/** Absolute path to the opencode binary (avoids PATH lookup issues in Electron). */
binaryPath?: string
}
export type TuiOptions = {
@ -31,7 +33,8 @@ export async function createOpencodeServer(options?: ServerOptions) {
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
const proc = spawn(`opencode`, args, {
const cmd = options.binaryPath ?? 'opencode'
const proc = spawn(cmd, args, {
signal: options.signal,
shell: process.platform === 'win32',
env: {

View file

@ -7,18 +7,30 @@
* discoverable too.
*/
import { writeFile, mkdir, unlink } from 'node:fs/promises'
import { writeFile, mkdir, unlink, readFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { homedir } from 'node:os'
const PORT_FILE_DIR = join(homedir(), '.openpencil')
const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port')
const PORT_FILE_TOKEN = randomUUID()
function getOwnerPid(): number {
return process.ppid > 1 ? process.ppid : process.pid
}
async function writePortFile(port: number): Promise<void> {
try {
await mkdir(PORT_FILE_DIR, { recursive: true })
await writeFile(
PORT_FILE_PATH,
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
JSON.stringify({
port,
pid: getOwnerPid(),
writerPid: process.pid,
token: PORT_FILE_TOKEN,
timestamp: Date.now(),
}),
'utf-8',
)
} catch {
@ -28,6 +40,9 @@ async function writePortFile(port: number): Promise<void> {
async function cleanupPortFile(): Promise<void> {
try {
const raw = await readFile(PORT_FILE_PATH, 'utf-8')
const current = JSON.parse(raw) as { token?: string }
if (current.token !== PORT_FILE_TOKEN) return
await unlink(PORT_FILE_PATH)
} catch {
// Ignore if already removed
@ -41,7 +56,6 @@ export default () => {
const cleanup = () => {
cleanupPortFile()
}
process.on('beforeExit', cleanup)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
}

View file

@ -0,0 +1,23 @@
import type { Agent } from '@zseven-w/agent'
export interface AgentSession {
agent: Agent
abortController: AbortController
createdAt: number
lastActivity: number
}
export const agentSessions = new Map<string, AgentSession>()
// Cleanup stale sessions every 60s (5-minute TTL from last activity)
setInterval(() => {
try {
const now = Date.now()
for (const [id, session] of agentSessions) {
if (now - session.lastActivity > 5 * 60 * 1000) {
try { session.abortController.abort() } catch { /* ignore */ }
agentSessions.delete(id)
}
}
} catch { /* ignore cleanup errors */ }
}, 60_000)

View file

@ -1,6 +1,6 @@
import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { serverLog } from './server-logger'
const isWindows = process.platform === 'win32'
@ -78,3 +78,45 @@ export function resolveCopilotCli(): string | undefined {
serverLog.warn('[resolve-copilot] no copilot binary found')
return undefined
}
/**
* On Windows, `.cmd` wrappers cannot be spawned directly by the copilot-sdk
* (it calls `spawn()` without `shell: true`, causing EINVAL).
* Resolve the `.cmd` to the actual `.js` entry point so the SDK can run it via node.
*/
export function resolveCliPathForSdk(cliPath: string): string {
if (!isWindows || !cliPath.endsWith('.cmd')) return cliPath
// npm .cmd wrappers live alongside node_modules — the JS entry is at:
// <dir>/node_modules/@github/copilot/index.js
const dir = dirname(cliPath)
const jsEntry = join(dir, 'node_modules', '@github', 'copilot', 'index.js')
if (existsSync(jsEntry)) {
serverLog.info(`[resolve-copilot] resolved .cmd → .js: ${jsEntry}`)
return jsEntry
}
// Fallback: parse the .cmd file to extract the JS path
// npm .cmd wrappers use %dp0% or %~dp0 to reference their own directory
try {
const content = readFileSync(cliPath, 'utf-8')
const match = content.match(/"([^"]+\.js)"/g)
if (match) {
for (const m of match) {
const jsPath = m
.replace(/"/g, '')
.replace(/%~?dp0%?\\/g, dir + '\\')
if (jsPath.includes('copilot') && existsSync(jsPath)) {
serverLog.info(`[resolve-copilot] parsed .cmd → .js: ${jsPath}`)
return jsPath
}
}
}
} catch (err) {
serverLog.warn(`[resolve-copilot] failed to parse .cmd: ${err instanceof Error ? err.message : err}`)
}
// Last resort: return as-is (will likely fail with EINVAL)
serverLog.warn(`[resolve-copilot] could not resolve .cmd to .js, using as-is: ${cliPath}`)
return cliPath
}

View file

@ -36,6 +36,13 @@ export function getSyncSelection(): { selectedIds: string[]; activePageId: strin
return { selectedIds: currentSelection, activePageId: currentActivePageId }
}
export function clearSyncState(): void {
currentDocument = null
documentVersion = 0
currentSelection = []
currentActivePageId = null
}
export function setSyncSelection(selectedIds: string[], activePageId?: string | null): void {
currentSelection = selectedIds
if (activePageId !== undefined) currentActivePageId = activePageId

View file

@ -3,6 +3,10 @@
* Reuses an existing server on port 4096; starts one on a random port as fallback.
* Tracks spawned servers so they can be cleaned up on process exit.
*/
import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
const activeServers = new Set<{ close(): void }>()
@ -18,7 +22,54 @@ process.on('beforeExit', cleanup)
process.on('SIGTERM', cleanup)
process.on('SIGINT', cleanup)
export async function getOpencodeClient() {
const isWindows = process.platform === 'win32'
/** Cached resolved binary path */
let _resolvedBinary: string | undefined | null = null
/** Resolve the opencode binary, with caching. */
function resolveOpencodeBinary(): string | undefined {
if (_resolvedBinary !== null) return _resolvedBinary ?? undefined
// PATH lookup
try {
const cmd = isWindows ? 'where opencode 2>nul' : 'which opencode 2>/dev/null'
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim()
if (result && existsSync(result)) {
_resolvedBinary = result
return result
}
} catch { /* not on PATH */ }
// Common install locations
const home = homedir()
const candidates = isWindows
? [
join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
join(process.env.NVM_SYMLINK || '', 'opencode.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'opencode.cmd'),
join(home, 'scoop', 'shims', 'opencode.exe'),
]
: [
join(home, '.opencode', 'bin', 'opencode'),
join(home, '.npm-global', 'bin', 'opencode'),
'/usr/local/bin/opencode',
'/opt/homebrew/bin/opencode',
join(home, '.local', 'bin', 'opencode'),
]
for (const c of candidates) {
if (c && existsSync(c)) {
_resolvedBinary = c
return c
}
}
_resolvedBinary = undefined
return undefined
}
export async function getOpencodeClient(binaryPath?: string) {
const { createOpencodeClient, createOpencode } = await import('../opencode/index')
// Try connecting to an existing server first
@ -28,7 +79,9 @@ export async function getOpencodeClient() {
return { client, server: undefined }
} catch {
// No running server — start a temporary one on a random port
const oc = await createOpencode({ port: 0 })
const resolvedPath = binaryPath ?? resolveOpencodeBinary()
const timeout = isWindows ? 15_000 : 5000
const oc = await createOpencode({ port: 0, binaryPath: resolvedPath, timeout })
activeServers.add(oc.server)
return { client: oc.client, server: oc.server }
}

View file

@ -142,36 +142,40 @@ export class SkiaEngine {
syncFromDocument() {
if (this.dragSyncSuppressed) return
const docState = useDocumentStore.getState()
const activePageId = useCanvasStore.getState().activePageId
const pageChildren = getActivePageChildren(docState.document, activePageId)
const allNodes = getAllChildren(docState.document)
try {
const docState = useDocumentStore.getState()
const activePageId = useCanvasStore.getState().activePageId
const pageChildren = getActivePageChildren(docState.document, activePageId)
const allNodes = getAllChildren(docState.document)
// Collect reusable/instance IDs from raw tree (before ref resolution strips them)
this.reusableIds.clear()
this.instanceIds.clear()
collectReusableIds(pageChildren, this.reusableIds)
collectInstanceIds(pageChildren, this.instanceIds)
// Collect reusable/instance IDs from raw tree (before ref resolution strips them)
this.reusableIds.clear()
this.instanceIds.clear()
collectReusableIds(pageChildren, this.reusableIds)
collectInstanceIds(pageChildren, this.instanceIds)
// Resolve refs, variables, then flatten
const resolved = resolveRefs(pageChildren, allNodes)
// Resolve refs, variables, then flatten
const resolved = resolveRefs(pageChildren, allNodes)
// Resolve design variables
const variables = docState.document.variables ?? {}
const themes = docState.document.themes
const defaultTheme = getDefaultTheme(themes)
const variableResolved = resolved.map((n) =>
resolveNodeForCanvas(n, variables, defaultTheme),
)
// Resolve design variables
const variables = docState.document.variables ?? {}
const themes = docState.document.themes
const defaultTheme = getDefaultTheme(themes)
const variableResolved = resolved.map((n) =>
resolveNodeForCanvas(n, variables, defaultTheme),
)
// Only premeasure text HEIGHTS for fixed-width text (where wrapping
// estimation may differ from Canvas 2D). Never touch widths or
// container-relative sizing to maintain layout consistency with Fabric.js.
const measured = premeasureTextHeights(variableResolved)
// Only premeasure text HEIGHTS for fixed-width text (where wrapping
// estimation may differ from Canvas 2D). Never touch widths or
// container-relative sizing to maintain layout consistency with Fabric.js.
const measured = premeasureTextHeights(variableResolved)
this.renderNodes = flattenToRenderNodes(measured)
this.renderNodes = flattenToRenderNodes(measured)
this.spatialIndex.rebuild(this.renderNodes)
this.spatialIndex.rebuild(this.renderNodes)
} catch (err) {
console.error('[SkiaEngine] syncFromDocument failed:', err)
}
this.markDirty()
}

View file

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'
import { Pencil, ChevronDown, Check, AlertTriangle } from 'lucide-react'
import { Pencil, ChevronDown, Check, AlertTriangle, Loader2, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import {
@ -42,12 +42,22 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag
if (items.length === 0) return null
const completed = items.filter((item) => item.done).length
const progress = items.length > 0 ? (completed / items.length) * 100 : 0
// Hide checklist when streaming stopped with nothing completed
if (!isStreaming && completed === 0) return null
return (
<div className="shrink-0 border-t border-border bg-card/95">
<div className="shrink-0 border-t border-border bg-card/95 backdrop-blur-sm">
{/* Progress bar */}
<div className="h-[2px] bg-secondary/50">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{/* Header */}
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
@ -57,8 +67,10 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag
<Pencil size={13} className="text-muted-foreground shrink-0" />
<span className="text-xs font-medium text-foreground">Pencil it out</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{completed}/{items.length}</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium tabular-nums text-muted-foreground bg-secondary/60 rounded-full px-1.5 py-0.5">
{completed}/{items.length}
</span>
<ChevronDown
size={12}
className={cn(
@ -68,50 +80,61 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag
/>
</div>
</button>
{/* Item list */}
{!collapsed && (
<div className="px-3 pb-2.5 flex max-h-44 flex-col gap-1 overflow-y-auto">
<div className="px-3 pb-2.5 flex max-h-48 flex-col gap-0.5 overflow-y-auto">
{items.map((item, index) => (
<div key={`${item.label}-${index}`} className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/90">
<div key={`${item.label}-${index}`} className="flex flex-col">
<div
className={cn(
'flex items-center gap-2.5 py-1 px-1.5 rounded-md text-xs transition-colors',
item.active ? 'bg-primary/[0.06]' : '',
)}
>
{/* Status indicator */}
{item.done ? (
<span className="w-4 h-4 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0">
<Check size={10} strokeWidth={2.5} className="text-emerald-500" />
</span>
) : item.active ? (
<Loader2 size={14} className="text-primary animate-spin shrink-0" />
) : (
<Circle size={14} className="text-muted-foreground/30 shrink-0" />
)}
{/* Label */}
<span
className={cn(
'w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0',
'truncate',
item.done
? 'border-emerald-500/70 text-emerald-500/80'
? 'text-muted-foreground'
: item.active
? 'border-primary/70 text-primary'
: 'border-border/70 text-muted-foreground/50',
? 'text-foreground font-medium'
: 'text-muted-foreground/60',
)}
>
{item.done ? (
<Check size={9} strokeWidth={2.5} />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
item.active ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60',
)} />
)}
{item.label}
</span>
<span className={cn(item.active ? 'text-foreground' : '')}>{item.label}</span>
</div>
{/* Detail lines */}
{item.details && item.details.length > 0 && (
<div className="ml-[22px] flex flex-col gap-px">
<div className="ml-[30px] flex flex-col gap-px pb-0.5">
{item.details.map((line, di) => {
const { status, text } = parseDetailStatus(line)
return (
<span key={di} className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
<span key={di} className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
{status === 'done' && (
<span className="w-2.5 h-2.5 rounded-full border border-emerald-500/70 text-emerald-500/80 flex items-center justify-center shrink-0">
<Check size={7} strokeWidth={2.5} />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0">
<Check size={7} strokeWidth={2.5} className="text-emerald-500" />
</span>
)}
{status === 'pending' && (
<span className="w-2.5 h-2.5 rounded-full border border-primary/70 flex items-center justify-center shrink-0">
<span className="w-1 h-1 rounded-full bg-primary animate-pulse" />
</span>
<Loader2 size={9} className="text-primary/70 animate-spin shrink-0" />
)}
{status === 'error' && (
<AlertTriangle size={10} className="text-amber-500/80 shrink-0" />
<AlertTriangle size={9} className="text-amber-500/80 shrink-0" />
)}
<span>{text}</span>
</span>

View file

@ -1,9 +1,13 @@
import { useState, useCallback } from 'react'
import { nanoid } from 'nanoid'
import i18n from '@/i18n'
import { decodeAgentEvent } from '@zseven-w/agent'
import type { AgentEvent } from '@zseven-w/agent'
import { useAIStore } from '@/stores/ai-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { useDesignMdStore } from '@/stores/design-md-store'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import { getActivePageChildren } from '@/stores/document-tree-utils'
import { streamChat } from '@/services/ai/ai-service'
import { buildChatSystemPrompt } from '@/services/ai/ai-prompts'
@ -14,7 +18,10 @@ import {
extractAndApplyDesignModification,
} from '@/services/ai/design-generator'
import { trimChatHistory } from '@/services/ai/context-optimizer'
import { AgentToolExecutor } from '@/services/ai/agent-tool-executor'
import { createDesignToolRegistry } from '@/services/ai/agent-tools'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import type { ToolCallBlockData } from '@/components/panels/tool-call-block'
import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'
/** Intent classification prompt — lightweight LLM call to determine message routing */
@ -106,6 +113,264 @@ export function buildContextString(): string {
return parts.length > 0 ? `\n\n[Canvas context: ${parts.join('. ')}]` : ''
}
// ---------------------------------------------------------------------------
// Agent mode SSE stream handler
// ---------------------------------------------------------------------------
/** Agent-specific tool usage instructions — prepended to the dynamic skill-based prompt. */
const AGENT_TOOL_INSTRUCTIONS = `IMPORTANT: When the user asks you to create or design anything, you MUST call the generate_design tool with a descriptive prompt. Do NOT output JSON or code directly.
## Available Tools
- generate_design: Create complete designs. Pass a natural language description.
- snapshot_layout: View current canvas state.
- batch_get: Read specific nodes by ID.
- update_node: Modify existing node properties.
- delete_node: Remove nodes.`
/**
* Build the agent system prompt dynamically using pen-ai-skills.
* Combines agent tool instructions with the same design knowledge the CLI pipeline uses.
*/
function buildAgentSystemPrompt(userMessage: string): string {
// Loads design skills dynamically based on user message keywords —
// same knowledge base the CLI pipeline uses (via pen-ai-skills)
const designKnowledge = buildChatSystemPrompt(userMessage)
return `${AGENT_TOOL_INSTRUCTIONS}\n\n${designKnowledge}`
}
/**
* Parse SSE chunks from a ReadableStream and yield AgentEvents.
* Handles partial chunks that may be split across reads.
*/
async function* parseAgentSSE(
reader: ReadableStreamDefaultReader<Uint8Array>,
signal: AbortSignal,
): AsyncGenerator<AgentEvent> {
const decoder = new TextDecoder()
let buffer = ''
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split('\n\n')
// Last item may be incomplete -- keep it in the buffer
buffer = chunks.pop() ?? ''
for (const chunk of chunks) {
const trimmed = chunk.trim()
if (!trimmed) continue
const evt = decodeAgentEvent(trimmed)
if (evt) yield evt
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
const evt = decodeAgentEvent(buffer.trim())
if (evt) yield evt
}
}
/** Provider config for the agent pipeline */
interface AgentProviderConfig {
providerType: 'anthropic' | 'openai-compat'
apiKey: string
model: string
baseURL?: string
maxOutputTokens?: number
}
/** Strip <think>...</think> tags (closed and unclosed) from model text output. */
function stripThinkTags(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>\s*/g, '').replace(/<think>[\s\S]*$/g, '')
}
/**
* Send a message through the agent pipeline.
* Opens an SSE connection to /api/ai/agent, dispatches tool calls
* client-side, and updates the AI store in real time.
*/
async function runAgentStream(
assistantMsgId: string,
providerConfig: AgentProviderConfig,
abortController: AbortController,
) {
const store = useAIStore.getState()
const { updateLastMessage } = store
const sessionId = nanoid()
const executor = new AgentToolExecutor(sessionId)
// Build tool definitions from the registry.
// The server uses these to register tools with the AI SDK.
const registry = createDesignToolRegistry()
const sdkTools = registry.toAISDKFormat()
const toolDefs = registry.list().map((t) => {
// Extract the JSON Schema from the AI SDK tool object
const sdkTool = sdkTools[t.name] as any
const parameters = sdkTool?.parameters?.jsonSchema ?? sdkTool?.inputSchema?.jsonSchema
return {
name: t.name,
description: t.description,
level: t.level,
parameters,
}
})
// Build conversation messages from chat history
const messages = useAIStore.getState().messages
.filter((m) => m.id !== assistantMsgId)
.map((m) => ({ role: m.role, content: m.content }))
const context = buildContextString()
// Build the last user message for skill resolution
const lastUserMsg = messages[messages.length - 1]?.content ?? ''
const systemPrompt = buildAgentSystemPrompt(lastUserMsg) + context
const agentBody: Record<string, unknown> = {
sessionId,
messages,
systemPrompt,
providerType: providerConfig.providerType,
apiKey: providerConfig.apiKey,
model: providerConfig.model,
...(providerConfig.baseURL ? { baseURL: providerConfig.baseURL } : {}),
...(providerConfig.maxOutputTokens ? { maxOutputTokens: providerConfig.maxOutputTokens } : {}),
toolDefs,
maxTurns: 20,
}
// Auto team mode: always create a designer member using the same provider as chat.
// The designer has a specialized prompt + scoped tools (generate_design only).
// Lead handles conversation; designer handles design generation.
if (providerConfig.apiKey) {
;(agentBody as any).members = [{
id: 'designer',
providerType: providerConfig.providerType,
apiKey: providerConfig.apiKey,
model: providerConfig.model,
baseURL: providerConfig.baseURL,
systemPrompt: 'You are a design specialist. Use the generate_design tool to create designs based on the task description. Focus on high-quality visual output.',
}]
}
const response = await fetch('/api/ai/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(agentBody),
signal: abortController.signal,
})
if (!response.ok || !response.body) {
const errText = await response.text().catch(() => 'Unknown error')
throw new Error(`Agent request failed: ${errText}`)
}
const reader = response.body.getReader()
let accumulated = ''
let thinkingContent = ''
try {
for await (const evt of parseAgentSSE(reader, abortController.signal)) {
switch (evt.type) {
case 'thinking': {
thinkingContent += evt.content
const thinkingStep = `<step title="Thinking">${thinkingContent}</step>`
updateLastMessage(thinkingStep + (accumulated ? '\n' + accumulated : ''))
break
}
case 'text': {
accumulated += evt.content
const prefix = thinkingContent
? `<step title="Thinking">${thinkingContent}</step>\n`
: ''
updateLastMessage(prefix + stripThinkTags(accumulated))
break
}
case 'tool_call': {
const block: ToolCallBlockData = {
id: evt.id,
name: evt.name,
args: evt.args,
level: evt.level,
status: 'running',
source: evt.source,
}
useAIStore.getState().addToolCallBlock(block)
// Execute tool client-side and post result back to server
executor.execute(evt as Extract<AgentEvent, { type: 'tool_call' }>).then(() => {
// Also update block status locally in case SSE tool_result event is lost
const block = useAIStore.getState().toolCallBlocks.find((b) => b.id === evt.id)
if (block && block.status === 'running') {
useAIStore.getState().updateToolCallBlock(evt.id, { status: 'done', result: { success: true } })
}
}).catch((err) => {
useAIStore.getState().updateToolCallBlock(evt.id, {
status: 'error',
result: { success: false, error: String(err) },
})
})
break
}
case 'tool_result': {
useAIStore.getState().updateToolCallBlock(evt.id, {
status: evt.result.success ? 'done' : 'error',
result: evt.result,
})
break
}
case 'turn': {
// Optionally show turn progress
break
}
case 'done': {
// If no text was accumulated (model returned empty), add a note
if (!accumulated.trim()) {
accumulated = '*Agent completed with no text output.*'
updateLastMessage(accumulated)
}
break
}
case 'error': {
accumulated += `\n\n**Error:** ${evt.message}`
updateLastMessage(accumulated)
if (evt.fatal) return
break
}
case 'member_start': {
accumulated += `\n\n> **[${evt.memberId}]** ${evt.task}\n`
updateLastMessage(accumulated)
break
}
case 'member_end': {
accumulated += `\n> **[${evt.memberId}]** done\n\n`
updateLastMessage(accumulated)
break
}
case 'abort': {
return
}
}
}
} finally {
reader.releaseLock()
}
return stripThinkTags(accumulated)
}
/** Shared chat logic hook */
export function useChatHandlers() {
const [input, setInput] = useState('')
@ -117,6 +382,7 @@ export function useChatHandlers() {
const addMessage = useAIStore((s) => s.addMessage)
const updateLastMessage = useAIStore((s) => s.updateLastMessage)
const setStreaming = useAIStore((s) => s.setStreaming)
const handleSend = useCallback(
async (text?: string) => {
const messageText = text ?? input.trim()
@ -162,22 +428,92 @@ export function useChatHandlers() {
useAIStore.getState().setChatTitle(title || 'New Chat')
}
const currentProvider = useAIStore.getState().modelGroups.find((g) =>
g.models.some((m) => m.value === model),
)?.provider
const abortController = new AbortController()
useAIStore.getState().setAbortController(abortController)
let accumulated = ''
// -----------------------------------------------------------------------
// BUILT-IN PROVIDER (Agent) MODE — route through /api/ai/agent with tool execution
// Triggered when the selected model has a `builtin:` prefix
// -----------------------------------------------------------------------
if (model.startsWith('builtin:')) {
const parts = model.split(':')
const builtinProviderId = parts[1]
const modelName = parts.slice(2).join(':')
const { builtinProviders } = useAgentSettingsStore.getState()
const bp = builtinProviders.find((p) => p.id === builtinProviderId)
if (!bp || !bp.apiKey) {
accumulated = !bp
? `**Error:** ${i18n.t('builtin.errorProviderNotFound')}`
: `**Error:** ${i18n.t('builtin.errorApiKeyEmpty')}`
updateLastMessage(accumulated)
useAIStore.getState().setAbortController(null)
setStreaming(false)
useAIStore.setState((s) => {
const msgs = [...s.messages]
const last = msgs.find((m) => m.id === assistantMsg.id)
if (last) { last.content = accumulated; last.isStreaming = false }
return { messages: msgs }
})
return
}
useAIStore.getState().clearToolCallBlocks()
try {
const result = await runAgentStream(
assistantMsg.id,
{
providerType: bp.type === 'anthropic' ? 'anthropic' : 'openai-compat',
apiKey: bp.apiKey,
model: modelName,
baseURL: bp.baseURL,
maxOutputTokens: bp.maxContextTokens ? Math.min(bp.maxContextTokens, 8192) : undefined,
},
abortController,
)
accumulated = result ?? ''
} catch (error) {
if (!abortController.signal.aborted) {
const errMsg = error instanceof Error ? error.message : 'Unknown error'
accumulated += `\n\n**Error:** ${errMsg}`
updateLastMessage(accumulated)
}
} finally {
useAIStore.getState().setAbortController(null)
setStreaming(false)
}
// Force update final message state
useAIStore.setState((s) => {
const msgs = [...s.messages]
const last = msgs.find((m) => m.id === assistantMsg.id)
if (last) {
last.content = accumulated
last.isStreaming = false
}
return { messages: msgs }
})
return
}
// -----------------------------------------------------------------------
// STANDARD MODE — existing design/chat pipeline
// -----------------------------------------------------------------------
const chatHistory = messages.map((m) => ({
role: m.role,
content: m.content,
...(m.attachments?.length ? { attachments: m.attachments } : {}),
}))
const currentProvider = useAIStore.getState().modelGroups.find((g) =>
g.models.some((m) => m.value === model),
)?.provider
let accumulated = ''
let appliedCount = 0
let isDesign = false
const abortController = new AbortController()
useAIStore.getState().setAbortController(abortController)
try {
// Classify intent via lightweight LLM call (three-way: new / modify / chat)
const classified = await classifyIntent(

View file

@ -0,0 +1,184 @@
import { useState, useRef, useEffect } from 'react'
import { Check, Search, X, Zap, Key } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { useAIStore } from '@/stores/ai-store'
import type { AIProviderType, ModelGroup } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import OpenCodeLogo from '@/components/icons/opencode-logo'
import CopilotLogo from '@/components/icons/copilot-logo'
import GeminiLogo from '@/components/icons/gemini-logo'
const PROVIDER_ICON: Record<AIProviderType, typeof ClaudeLogo> = {
anthropic: ClaudeLogo,
openai: OpenAILogo,
opencode: OpenCodeLogo,
copilot: CopilotLogo,
gemini: GeminiLogo,
}
export { PROVIDER_ICON }
/** Pick the best available model: keep current, fall back to preferred, then first. */
export function resolveNextModel(
models: Array<{ value: string }>,
currentModel: string,
preferredModel: string,
): string | null {
if (models.length === 0) return null
if (models.some((m) => m.value === currentModel)) return currentModel
if (models.some((m) => m.value === preferredModel)) return preferredModel
return models[0].value
}
/**
* Compact concurrency selector cycles 1x through 6x on click.
*/
export function ConcurrencyButton() {
const { t } = useTranslation()
const concurrency = useAIStore((s) => s.concurrency)
const setConcurrency = useAIStore((s) => s.setConcurrency)
const isStreaming = useAIStore((s) => s.isStreaming)
return (
<button
type="button"
onClick={() => setConcurrency(concurrency >= 6 ? 1 : concurrency + 1)}
disabled={isStreaming}
title={t('builtin.parallelAgents', { count: concurrency })}
className={cn(
'flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-md transition-colors shrink-0',
concurrency > 1
? 'text-primary bg-primary/10 hover:bg-primary/20'
: 'text-muted-foreground/50 hover:text-muted-foreground hover:bg-secondary',
)}
>
<Zap size={10} />
<span>{concurrency}x</span>
</button>
)
}
/**
* Upward model dropdown with search, provider grouping, and builtin badges.
*/
export function ModelDropdown({
open,
onClose,
}: {
open: boolean
onClose: () => void
}) {
const { t } = useTranslation()
const availableModels = useAIStore((s) => s.availableModels)
const modelGroups = useAIStore((s) => s.modelGroups) as ModelGroup[]
const model = useAIStore((s) => s.model)
const selectModel = useAIStore((s) => s.selectModel)
const [modelSearch, setModelSearch] = useState('')
const modelSearchRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setModelSearch('')
setTimeout(() => modelSearchRef.current?.focus(), 50)
}
}, [open])
// Close on outside click
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, onClose])
if (!open || availableModels.length === 0) return null
const q = modelSearch.toLowerCase().trim()
const renderGrouped = () => {
const filtered = modelGroups
.map((group) => ({
...group,
models: group.models.filter(
(m) => !q || m.displayName.toLowerCase().includes(q) || m.value.toLowerCase().includes(q) || group.providerName.toLowerCase().includes(q),
),
}))
.filter((group) => group.models.length > 0)
if (filtered.length === 0) {
return <div className="px-3 py-4 text-xs text-muted-foreground text-center">{t('ai.noModelsFound')}</div>
}
return filtered.map((group, groupIdx) => {
const GIcon = PROVIDER_ICON[group.provider]
const groupKey = `${group.provider}-${group.providerName}-${groupIdx}`
const isBuiltinGroup = group.models.some((m) => m.value.startsWith('builtin:'))
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" /> : <GIcon className="w-3 h-3 text-muted-foreground" />}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">{group.providerName}</span>
</div>
{group.models.map((m, idx) => {
const isSelected = m.value === model
const isBuiltin = m.value.startsWith('builtin:')
return (
<button key={m.value} type="button" onClick={() => { selectModel(m.value); onClose() }}
className={cn('w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors',
isSelected ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
<span className="w-3.5 shrink-0">{isSelected && <Check size={12} />}</span>
<span className="font-medium">{m.displayName}</span>
{isBuiltin && <span className="text-[9px] text-muted-foreground bg-secondary px-1 py-0.5 rounded ml-auto">{t('builtin.apiKeyBadge')}</span>}
{!isBuiltin && idx === 0 && !q && <span className="text-[9px] text-muted-foreground bg-secondary px-1 py-0.5 rounded ml-auto">{t('common.best')}</span>}
</button>
)
})}
</div>
)
})
}
const renderFlat = () => {
const filtered = availableModels.filter((m) => !q || m.displayName.toLowerCase().includes(q) || m.value.toLowerCase().includes(q))
if (filtered.length === 0) {
return <div className="px-3 py-4 text-xs text-muted-foreground text-center">{t('ai.noModelsFound')}</div>
}
return filtered.map((m) => {
const isSelected = m.value === model
return (
<button key={m.value} type="button" onClick={() => { selectModel(m.value); onClose() }}
className={cn('w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors',
isSelected ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
<span className="w-3.5 shrink-0">{isSelected && <Check size={12} />}</span>
<span className="font-medium">{m.displayName}</span>
</button>
)
})
}
return (
<div ref={dropdownRef} className="absolute bottom-full left-0 right-0 mb-1 z-[60] rounded-lg border border-border bg-card shadow-xl py-1 max-h-72 flex flex-col">
<div className="px-2 pt-1 pb-1.5 border-b border-border shrink-0">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-secondary/50">
<Search size={12} className="text-muted-foreground shrink-0" />
<input ref={modelSearchRef} value={modelSearch} onChange={(e) => setModelSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') onClose() }}
placeholder={t('ai.searchModels')} className="w-full bg-transparent text-xs text-foreground placeholder-muted-foreground outline-none" />
{modelSearch && (
<button type="button" onClick={() => setModelSearch('')} className="text-muted-foreground hover:text-foreground shrink-0"><X size={10} /></button>
)}
</div>
</div>
<div className="overflow-y-auto">
{modelGroups.length > 0 ? renderGrouped() : renderFlat()}
</div>
</div>
)
}

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2, Paperclip, X, Square, Zap, Search } from 'lucide-react'
import { Send, Plus, ChevronDown, ChevronUp, MessageSquare, Loader2, Paperclip, X, Square, Key } from 'lucide-react'
import { nanoid } from 'nanoid'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
@ -12,22 +12,12 @@ import {
extractAndApplyDesign,
} from '@/services/ai/design-generator'
import type { AIProviderType } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import OpenCodeLogo from '@/components/icons/opencode-logo'
import CopilotLogo from '@/components/icons/copilot-logo'
import GeminiLogo from '@/components/icons/gemini-logo'
import ChatMessage from './chat-message'
import { useChatHandlers } from './ai-chat-handlers'
import { FixedChecklist } from './ai-chat-checklist'
import { ToolCallBlock } from './tool-call-block'
import { PROVIDER_ICON, resolveNextModel, ConcurrencyButton, ModelDropdown } from './ai-chat-model-selector'
const PROVIDER_ICON: Record<AIProviderType, typeof ClaudeLogo> = {
anthropic: ClaudeLogo,
openai: OpenAILogo,
opencode: OpenCodeLogo,
copilot: CopilotLogo,
gemini: GeminiLogo,
}
const QUICK_ACTIONS = [
{
@ -55,49 +45,6 @@ const CORNER_CLASSES: Record<PanelCorner, string> = {
'bottom-right': 'bottom-3 right-3',
}
function resolveNextModel(
models: Array<{ value: string }>,
currentModel: string,
preferredModel: string,
): string | null {
if (models.length === 0) return null
if (models.some((m) => m.value === currentModel)) return currentModel
if (models.some((m) => m.value === preferredModel)) return preferredModel
return models[0].value
}
/**
* Compact concurrency selector cycles 1x through 6x on click.
* Only visually prominent when concurrency > 1.
*/
function ConcurrencyButton() {
const concurrency = useAIStore((s) => s.concurrency)
const setConcurrency = useAIStore((s) => s.setConcurrency)
const isStreaming = useAIStore((s) => s.isStreaming)
const handleClick = () => {
// Cycle: 1 → 2 → 3 → 4 → 5 → 6 → 1
setConcurrency(concurrency >= 6 ? 1 : concurrency + 1)
}
return (
<button
type="button"
onClick={handleClick}
disabled={isStreaming}
title={`Parallel sub-agents: ${concurrency}x (click to cycle)`}
className={cn(
'flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-md transition-colors shrink-0',
concurrency > 1
? 'text-primary bg-primary/10 hover:bg-primary/20'
: 'text-muted-foreground/50 hover:text-muted-foreground hover:bg-secondary',
)}
>
<Zap size={10} />
<span>{concurrency}x</span>
</button>
)
}
/**
* Minimized AI bar a compact clickable pill.
@ -152,7 +99,6 @@ export default function AIChatPanel() {
const hydrateModelPreference = useAIStore((s) => s.hydrateModelPreference)
const model = useAIStore((s) => s.model)
const setModel = useAIStore((s) => s.setModel)
const selectModel = useAIStore((s) => s.selectModel)
const availableModels = useAIStore((s) => s.availableModels)
const setAvailableModels = useAIStore((s) => s.setAvailableModels)
const modelGroups = useAIStore((s) => s.modelGroups)
@ -160,10 +106,10 @@ export default function AIChatPanel() {
const isLoadingModels = useAIStore((s) => s.isLoadingModels)
const setLoadingModels = useAIStore((s) => s.setLoadingModels)
const providers = useAgentSettingsStore((s) => s.providers)
const builtinProviders = useAgentSettingsStore((s) => s.builtinProviders)
const providersHydrated = useAgentSettingsStore((s) => s.isHydrated)
const [modelDropdownOpen, setModelDropdownOpen] = useState(false)
const [modelSearch, setModelSearch] = useState('')
const modelSearchRef = useRef<HTMLInputElement>(null)
const toolCallBlocks = useAIStore((s) => s.toolCallBlocks)
const pendingAttachments = useAIStore((s) => s.pendingAttachments)
const addPendingAttachment = useAIStore((s) => s.addPendingAttachment)
const removePendingAttachment = useAIStore((s) => s.removePendingAttachment)
@ -182,32 +128,50 @@ export default function AIChatPanel() {
hydrateModelPreference()
}, [hydrateModelPreference])
// Build model list strictly from connected providers in agent-settings-store.
// If none are connected, model list is empty.
// Build model list from connected CLI providers + enabled built-in providers.
useEffect(() => {
if (!providersHydrated) {
setLoadingModels(true)
return
}
const providerNames: Record<AIProviderType, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode',
copilot: 'GitHub Copilot',
gemini: 'Google Gemini',
}
// Collect CLI-connected providers
const connectedProviders = (Object.keys(providers) as AIProviderType[]).filter(
(p) => providers[p].isConnected && (providers[p].models?.length ?? 0) > 0,
)
if (connectedProviders.length > 0) {
// Build groups + flat list from stored models
const providerNames: Record<AIProviderType, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode',
copilot: 'GitHub Copilot',
gemini: 'Google Gemini',
}
const groups = connectedProviders.map((p) => ({
provider: p,
providerName: providerNames[p],
models: providers[p].models,
}))
const groups = connectedProviders.map((p) => ({
provider: p,
providerName: providerNames[p],
models: providers[p].models,
}))
// Also collect enabled built-in providers with API keys
for (const bp of builtinProviders) {
if (!bp.enabled || !bp.apiKey) continue
const providerType: AIProviderType = bp.type === 'anthropic' ? 'anthropic' : 'openai'
groups.push({
provider: providerType,
providerName: bp.displayName || (bp.type === 'anthropic' ? 'Anthropic (API Key)' : bp.displayName),
models: [{
value: `builtin:${bp.id}:${bp.model}`,
displayName: bp.model,
description: t('builtin.viaApiKey', { name: bp.displayName }),
provider: providerType,
builtinProviderId: bp.id,
}],
})
}
if (groups.length > 0) {
const flat = groups.flatMap((g) =>
g.models.map((m) => ({
value: m.value,
@ -231,24 +195,8 @@ export default function AIChatPanel() {
setModelGroups([])
setAvailableModels([])
setLoadingModels(false)
}, [providers, providersHydrated]) // eslint-disable-line react-hooks/exhaustive-deps
}, [providers, builtinProviders, providersHydrated]) // eslint-disable-line react-hooks/exhaustive-deps
// Close model dropdown when clicking outside
useEffect(() => {
if (!modelDropdownOpen) {
setModelSearch('')
return
}
requestAnimationFrame(() => modelSearchRef.current?.focus())
const handler = (e: MouseEvent) => {
const panel = panelRef.current
if (panel && !panel.contains(e.target as Node)) {
setModelDropdownOpen(false)
}
}
document.addEventListener('pointerdown', handler)
return () => document.removeEventListener('pointerdown', handler)
}, [modelDropdownOpen])
// Auto-expand when streaming starts while minimized
useEffect(() => {
@ -564,16 +512,26 @@ export default function AIChatPanel() {
</p>
</div>
) : (
messages.map((msg) => (
<ChatMessage
key={msg.id}
role={msg.role}
content={msg.content}
isStreaming={msg.isStreaming && isStreaming}
onApplyDesign={handleApplyDesign}
attachments={msg.attachments}
/>
))
<>
{messages.map((msg) => (
<ChatMessage
key={msg.id}
role={msg.role}
content={msg.content}
isStreaming={msg.isStreaming && isStreaming}
onApplyDesign={handleApplyDesign}
attachments={msg.attachments}
/>
))}
{/* Tool call blocks (built-in provider / agent pipeline) */}
{toolCallBlocks.length > 0 && (
<div className="mt-1">
{toolCallBlocks.map((block) => (
<ToolCallBlock key={block.id} block={block} />
))}
</div>
)}
</>
)}
<div ref={messagesEndRef} />
</div>
@ -636,6 +594,19 @@ export default function AIChatPanel() {
className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors px-1.5 py-1 rounded-md hover:bg-secondary"
>
{(() => {
// Show Key icon for built-in provider models
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" />
}
const currentProvider = modelGroups.find((g) =>
g.models.some((m) => m.value === model),
)?.provider
@ -711,147 +682,7 @@ export default function AIChatPanel() {
</div>
{/* Upward model dropdown */}
{modelDropdownOpen && availableModels.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 z-[60] rounded-lg border border-border bg-card shadow-xl py-1 max-h-72 flex flex-col">
{/* Search input */}
<div className="px-2 pt-1 pb-1.5 border-b border-border shrink-0">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-secondary/50">
<Search size={12} className="text-muted-foreground shrink-0" />
<input
ref={modelSearchRef}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setModelDropdownOpen(false)
}
}}
placeholder={t('ai.searchModels')}
className="w-full bg-transparent text-xs text-foreground placeholder-muted-foreground outline-none"
/>
{modelSearch && (
<button
type="button"
onClick={() => setModelSearch('')}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X size={10} />
</button>
)}
</div>
</div>
<div className="overflow-y-auto">
{(() => {
const q = modelSearch.toLowerCase().trim()
if (modelGroups.length > 0) {
const filtered = modelGroups
.map((group) => ({
...group,
models: group.models.filter(
(m) =>
!q ||
m.displayName.toLowerCase().includes(q) ||
m.value.toLowerCase().includes(q) ||
group.providerName.toLowerCase().includes(q),
),
}))
.filter((group) => group.models.length > 0)
if (filtered.length === 0) {
return (
<div className="px-3 py-4 text-xs text-muted-foreground text-center">
{t('ai.noModelsFound')}
</div>
)
}
return filtered.map((group) => {
const GIcon = PROVIDER_ICON[group.provider]
return (
<div key={group.provider}>
<div className="flex items-center gap-1.5 px-3 pt-2.5 pb-1">
<GIcon className="w-3 h-3 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
{group.providerName}
</span>
</div>
{group.models.map((m, idx) => {
const isSelected = m.value === model
return (
<button
key={m.value}
type="button"
onClick={() => {
selectModel(m.value)
setModelDropdownOpen(false)
}}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors',
isSelected
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<span className="w-3.5 shrink-0">
{isSelected && <Check size={12} />}
</span>
<span className="font-medium">{m.displayName}</span>
{idx === 0 && !q && (
<span className="text-[9px] text-muted-foreground bg-secondary px-1 py-0.5 rounded ml-auto">
{t('common.best')}
</span>
)}
</button>
)
})}
</div>
)
})
}
const filtered = availableModels.filter(
(m) =>
!q ||
m.displayName.toLowerCase().includes(q) ||
m.value.toLowerCase().includes(q),
)
if (filtered.length === 0) {
return (
<div className="px-3 py-4 text-xs text-muted-foreground text-center">
{t('ai.noModelsFound')}
</div>
)
}
return filtered.map((m) => {
const isSelected = m.value === model
return (
<button
key={m.value}
type="button"
onClick={() => {
selectModel(m.value)
setModelDropdownOpen(false)
}}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors',
isSelected
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<span className="w-3.5 shrink-0">
{isSelected && <Check size={12} />}
</span>
<span className="font-medium">{m.displayName}</span>
</button>
)
})
})()}
</div>
</div>
)}
<ModelDropdown open={modelDropdownOpen} onClose={() => setModelDropdownOpen(false)} />
</div>
</div>
</div>

View file

@ -1,154 +1,229 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { Copy, Check, Sparkles, Loader2, RotateCcw, Download, ChevronLeft, ChevronRight } from 'lucide-react'
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
import {
Copy, Download, RefreshCw, Sparkles,
Check, Loader2, AlertTriangle, MinusCircle, SkipForward,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
import { useAIStore } from '@/stores/ai-store'
import { streamChat } from '@/services/ai/ai-service'
import { generateReactCode } from '@/services/codegen/react-generator'
import { generateHTMLCode } from '@/services/codegen/html-generator'
import { generateVueCode } from '@/services/codegen/vue-generator'
import { generateSvelteCode } from '@/services/codegen/svelte-generator'
import { generateSwiftUICode } from '@/services/codegen/swiftui-generator'
import { generateComposeCode } from '@/services/codegen/compose-generator'
import { generateFlutterCode } from '@/services/codegen/flutter-generator'
import { generateReactNativeCode } from '@/services/codegen/react-native-generator'
import { generateCSSVariables } from '@/services/codegen/css-variables-generator'
import { generateCode } from '@/services/ai/code-generation-pipeline'
import { highlightCode } from '@/utils/syntax-highlight'
import type { Framework, CodeGenProgress, ChunkStatus } from '@zseven-w/pen-codegen'
import { FRAMEWORKS } from '@zseven-w/pen-codegen'
import type { PenNode } from '@/types/pen'
import type { SyntaxLanguage } from '@/utils/syntax-highlight'
type CodeTab = 'react' | 'vue' | 'svelte' | 'html' | 'swiftui' | 'compose' | 'flutter' | 'react-native' | 'css-vars'
type PanelState = 'empty' | 'generating' | 'complete'
const ENHANCE_SYSTEM_PROMPT = `You are a code rewriter. You receive auto-generated UI code and rewrite it to be idiomatic, production-ready, and RESPONSIVE.
interface ChunkProgress {
chunkId: string
name: string
status: ChunkStatus
error?: string
}
CRITICAL: Your ENTIRE response must be ONLY the improved source code. Nothing else.
- Do NOT include explanations, commentary, reasoning, or thinking.
- Do NOT include markdown fences (\`\`\`), XML tags, or tool calls.
- Do NOT prefix with "Here is" or any preamble.
- Start your response with the first line of code and end with the last line.
const TAB_LABELS: Record<Framework, string> = {
react: 'React',
vue: 'Vue',
svelte: 'Svelte',
html: 'HTML',
flutter: 'Flutter',
swiftui: 'SwiftUI',
compose: 'Compose',
'react-native': 'RN',
}
Rewriting rules:
- Preserve visual fidelity the output must look identical to the input design on desktop.
- Use semantic HTML where appropriate (nav, header, main, section, article, etc.).
- Replace absolute pixel positioning with proper layout (flexbox/grid) where possible.
- Use meaningful class/variable names derived from the node names.
- Keep the same framework and language as the input.
- For CSS-in-JS or scoped styles, keep styles co-located.
- For SwiftUI/Compose/Flutter, use idiomatic patterns (SwiftUI: adaptive stacks, Compose: adaptive layouts, Flutter: LayoutBuilder/MediaQuery).
- Do not add functionality, interactivity, or state beyond what exists.
- If design variables are present as var(--name), preserve them.
RESPONSIVE DESIGN (CRITICAL make the output adapt gracefully across screen sizes):
- Convert fixed pixel widths to relative units (%, max-w-*, flex-1, w-full) where appropriate. Keep max-width constraints for readability.
- Use responsive Tailwind breakpoints (sm:, md:, lg:) for layout changes:
- Multi-column rows (horizontal layouts) should stack vertically on small screens (flex-col sm:flex-row or md:flex-row).
- Grid layouts: use responsive column counts (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3).
- Adjust padding/gap with responsive variants (p-4 md:p-8 lg:p-12).
- Font sizes: scale down on mobile (text-2xl md:text-4xl lg:text-5xl).
- Images: use w-full max-w-* with aspect ratio preservation, never fixed px width.
- Container: use max-w-7xl mx-auto px-4 (or similar) for centered content with side padding.
- Navigation: consider collapsible patterns on small screens.
- For HTML+CSS: use @media queries with mobile-first breakpoints (min-width: 640px, 768px, 1024px).
- For Vue/Svelte: apply the same responsive CSS/class strategies.
- Do NOT break the design on desktop while making it responsive desktop must remain visually faithful.`
/** Strip markdown fences, reasoning preamble, and tool-call XML from AI output. */
function cleanEnhancedResult(raw: string): string {
let result = raw
// Strip everything before the first code-like line if the AI prepended reasoning
// Detect patterns like "I'll start...", "<tool_call>", "Let me..."
const reasoningPatterns = /^(I['']ll |Let me |Here['']s |Sure|Okay|<tool_call>|<tool_name>)/im
if (reasoningPatterns.test(result)) {
// Try to find the start of actual code after the reasoning
// Look for common code markers: import, export, <, struct, @, class, fun, <!DOCTYPE
const codeStart = result.search(/^(import |export |<[!a-zA-Z]|struct |@Composable|@override|class |fun |<!DOCTYPE|package )/m)
if (codeStart > 0) {
result = result.slice(codeStart)
}
}
// Strip markdown fences
if (result.startsWith('```')) {
const firstNewline = result.indexOf('\n')
const lastFence = result.lastIndexOf('```')
if (lastFence > firstNewline) {
result = result.slice(firstNewline + 1, lastFence).trimEnd()
}
}
return result
const HIGHLIGHT_LANG: Record<Framework, SyntaxLanguage> = {
react: 'jsx',
vue: 'html',
svelte: 'html',
html: 'html',
flutter: 'dart',
swiftui: 'swift',
compose: 'kotlin',
'react-native': 'jsx',
}
export default function CodePanel() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<CodeTab>('react')
const [activeTab, setActiveTab] = useState<Framework>('react')
const [codeCache, setCodeCache] = useState<Partial<Record<Framework, { code: string; degraded: boolean }>>>({})
const [isDegraded, setIsDegraded] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [copied, setCopied] = useState(false)
const [enhancedCode, setEnhancedCode] = useState<Record<string, string>>({})
const [isEnhancing, setIsEnhancing] = useState(false)
const enhanceAbortRef = useRef<AbortController | null>(null)
const [planningStatus, setPlanningStatus] = useState<'idle' | 'running' | 'done' | 'failed'>('idle')
const [planningError, setPlanningError] = useState<string>()
const [assemblyStatus, setAssemblyStatus] = useState<'idle' | 'running' | 'done' | 'failed'>('idle')
const [chunks, setChunks] = useState<ChunkProgress[]>([])
const [selectionChanged, setSelectionChanged] = useState(false)
const [generateError, setGenerateError] = useState<string>()
const cached = codeCache[activeTab]
const generatedCode = cached?.code ?? ''
const panelState: PanelState = isGenerating ? 'generating' : cached ? 'complete' : 'empty'
const abortRef = useRef<AbortController | null>(null)
const lastSelectionRef = useRef<string>('')
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const activePageId = useCanvasStore((s) => s.activePageId)
const children = useDocumentStore((s) => getActivePageChildren(s.document, activePageId))
const getNodeById = useDocumentStore((s) => s.getNodeById)
// Force re-render when document changes
void children
const selectedIds = useCanvasStore(s => s.selection.selectedIds)
const activePageId = useCanvasStore(s => s.activePageId)
const getNodeById = useDocumentStore(s => s.getNodeById)
const children = useDocumentStore(s => getActivePageChildren(s.document, activePageId))
const variables = useDocumentStore(s => s.document?.variables)
const model = useAIStore(s => s.model)
const provider = useAIStore(s =>
s.modelGroups.find(g => g.models.some(m => m.value === s.model))?.provider,
)
const targetNodes: PenNode[] = useMemo(() => {
const selectionKey = selectedIds.join(',')
// Detect selection changes when code is already generated
useEffect(() => {
if (panelState === 'complete' && selectionKey !== lastSelectionRef.current) {
setSelectionChanged(true)
}
}, [panelState, selectionKey])
// Cleanup on unmount
useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
abortRef.current?.abort()
}
}, [])
const getTargetNodes = useCallback((): PenNode[] => {
if (selectedIds.length > 0) {
return selectedIds
.map((id) => getNodeById(id))
.map(id => getNodeById(id))
.filter((n): n is PenNode => n !== undefined)
}
return children
}, [selectedIds, children, getNodeById])
}, [selectedIds, getNodeById, children])
const document = useDocumentStore((s) => s.document)
const handleGenerate = useCallback(async () => {
const nodes = getTargetNodes()
if (nodes.length === 0) return
const generatedCode = useMemo(() => {
switch (activeTab) {
case 'css-vars': return generateCSSVariables(document)
case 'react': return generateReactCode(targetNodes)
case 'vue': return generateVueCode(targetNodes)
case 'svelte': return generateSvelteCode(targetNodes)
case 'swiftui': return generateSwiftUICode(targetNodes)
case 'compose': return generateComposeCode(targetNodes)
case 'flutter': return generateFlutterCode(targetNodes)
case 'react-native': return generateReactNativeCode(targetNodes)
case 'html': {
const { html, css } = generateHTMLCode(targetNodes)
return `<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>Design</title>\n <style>\n${css.split('\n').map((l) => ` ${l}`).join('\n')}\n </style>\n</head>\n<body>\n${html.split('\n').map((l) => ` ${l}`).join('\n')}\n</body>\n</html>`
abortRef.current = new AbortController()
setIsGenerating(true)
setPlanningStatus('idle')
setPlanningError(undefined)
setAssemblyStatus('idle')
setChunks([])
setIsDegraded(false)
setSelectionChanged(false)
setGenerateError(undefined)
lastSelectionRef.current = selectionKey
const handleProgress = (event: CodeGenProgress) => {
switch (event.step) {
case 'planning':
setPlanningStatus(event.status)
if (event.error) setPlanningError(event.error)
break
case 'chunk':
setChunks(prev => {
const existing = prev.findIndex(c => c.chunkId === event.chunkId)
const entry: ChunkProgress = {
chunkId: event.chunkId,
name: event.name,
status: event.status,
error: event.error,
}
if (existing >= 0) {
const next = [...prev]
next[existing] = entry
return next
}
return [...prev, entry]
})
break
case 'assembly':
setAssemblyStatus(event.status)
break
case 'complete':
setCodeCache(prev => ({ ...prev, [activeTab]: { code: event.finalCode, degraded: event.degraded } }))
setIsDegraded(event.degraded)
setIsGenerating(false)
break
case 'error':
setGenerateError(event.message)
setIsGenerating(false)
break
}
}
}, [activeTab, targetNodes, document])
// Use enhanced code if available for this tab, otherwise the generated code
const displayCode = enhancedCode[activeTab] ?? generatedCode
try {
await generateCode(nodes, activeTab, variables, handleProgress, model, provider, abortRef.current.signal)
} catch (err) {
if (!abortRef.current?.signal.aborted) {
const msg = err instanceof Error ? err.message : 'Code generation failed'
setGenerateError(msg)
}
setIsGenerating(false)
}
}, [getTargetNodes, activeTab, variables, selectionKey, model, provider])
const handleCancel = useCallback(() => {
abortRef.current?.abort()
setIsGenerating(false)
}, [])
const handleRetryChunk = useCallback((_chunkId: string) => {
// Re-run the full pipeline (planning is fast, only failed/skipped chunks re-run)
void handleGenerate()
}, [handleGenerate])
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(generatedCode)
setCopied(true)
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
}, [generatedCode])
const handleDownload = useCallback(() => {
const extensions: Record<Framework, string> = {
react: '.tsx',
vue: '.vue',
svelte: '.svelte',
html: '.html',
flutter: '.dart',
swiftui: '.swift',
compose: '.kt',
'react-native': '.tsx',
}
const blob = new Blob([generatedCode], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = globalThis.document.createElement('a')
a.href = url
a.download = `design${extensions[activeTab]}`
a.click()
URL.revokeObjectURL(url)
}, [generatedCode, activeTab])
const handleTabChange = useCallback((tab: Framework) => {
setActiveTab(tab)
setGenerateError(undefined)
// isDegraded follows the cached tab's value
const tabCache = codeCache[tab]
setIsDegraded(tabCache?.degraded ?? false)
}, [codeCache])
const nodeCount = selectedIds.length > 0 ? selectedIds.length : children.length
const highlightedHTML = useMemo(() => {
const langMap: Record<CodeTab, Parameters<typeof highlightCode>[1]> = {
react: 'jsx',
vue: 'html',
svelte: 'html',
swiftui: 'swift',
compose: 'kotlin',
flutter: 'dart',
'react-native': 'jsx',
'css-vars': 'css',
html: 'html',
}
if (!generatedCode) return ''
const lang = HIGHLIGHT_LANG[activeTab]
// HTML / Vue / Svelte: split at <style to highlight CSS portion separately
if (activeTab === 'html' || activeTab === 'vue' || activeTab === 'svelte') {
const styleIdx = displayCode.indexOf('<style')
const styleIdx = generatedCode.indexOf('<style')
if (styleIdx !== -1) {
const templatePart = displayCode.slice(0, styleIdx)
const stylePart = displayCode.slice(styleIdx)
// Highlight the style tag line as HTML, then contents as CSS
const templatePart = generatedCode.slice(0, styleIdx)
const stylePart = generatedCode.slice(styleIdx)
const styleTagEnd = stylePart.indexOf('>\n')
if (styleTagEnd !== -1) {
const styleTag = stylePart.slice(0, styleTagEnd + 1)
@ -168,296 +243,186 @@ export default function CodePanel() {
return highlightCode(templatePart, 'html') + highlightCode(stylePart, 'css')
}
}
return highlightCode(displayCode, langMap[activeTab])
}, [activeTab, displayCode])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(displayCode).then(() => {
setCopied(true)
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
})
}, [displayCode])
const handleDownload = useCallback(() => {
const extMap: Record<CodeTab, string> = {
react: 'tsx',
vue: 'vue',
svelte: 'svelte',
html: 'html',
swiftui: 'swift',
compose: 'kt',
flutter: 'dart',
'react-native': 'tsx',
'css-vars': 'css',
}
const ext = extMap[activeTab]
const blob = new Blob([displayCode], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = globalThis.document.createElement('a')
a.href = url
a.download = `design.${ext}`
a.click()
URL.revokeObjectURL(url)
}, [activeTab, displayCode])
const hasAI = useAIStore((s) => s.availableModels.length > 0)
const handleEnhance = useCallback(async () => {
if (isEnhancing || activeTab === 'css-vars') return
const model = useAIStore.getState().model
const modelGroups = useAIStore.getState().modelGroups
const provider = modelGroups.find((g) =>
g.models.some((m) => m.value === model),
)?.provider
if (!model || !provider) return
setIsEnhancing(true)
const abortController = new AbortController()
enhanceAbortRef.current = abortController
// Build a compact node summary for context
const nodesSummary = JSON.stringify(
targetNodes.map((n) => {
const base: Record<string, unknown> = { type: n.type, name: n.name }
if ('width' in n) base.width = n.width
if ('height' in n) base.height = n.height
if ('children' in n && Array.isArray(n.children)) base.childCount = n.children.length
if ('layout' in n) base.layout = n.layout
return base
}),
)
const frameworkNames: Record<CodeTab, string> = {
react: 'React with Tailwind CSS',
vue: 'Vue 3 SFC',
svelte: 'Svelte',
html: 'HTML + CSS',
swiftui: 'SwiftUI',
compose: 'Jetpack Compose (Kotlin)',
flutter: 'Flutter (Dart)',
'react-native': 'React Native',
'css-vars': '',
}
const userMessage = `Framework: ${frameworkNames[activeTab]}
Design nodes (JSON summary):
${nodesSummary}
Auto-generated code to improve:
${generatedCode}`
try {
let result = ''
for await (const chunk of streamChat(
ENHANCE_SYSTEM_PROMPT,
[{ role: 'user', content: userMessage }],
model,
{ thinkingMode: 'disabled', effort: 'high' },
provider,
abortController.signal,
)) {
if (chunk.type === 'text') {
result += chunk.content
setEnhancedCode((prev) => ({ ...prev, [activeTab]: result }))
}
if (chunk.type === 'error') break
}
// Clean up artifacts the AI may have included despite instructions
result = cleanEnhancedResult(result)
setEnhancedCode((prev) => ({ ...prev, [activeTab]: result }))
} finally {
setIsEnhancing(false)
enhanceAbortRef.current = null
}
}, [isEnhancing, activeTab, generatedCode, targetNodes])
const handleCancelEnhance = useCallback(() => {
enhanceAbortRef.current?.abort()
setIsEnhancing(false)
}, [])
const handleResetEnhance = useCallback(() => {
setEnhancedCode((prev) => {
const next = { ...prev }
delete next[activeTab]
return next
})
}, [activeTab])
// Clear enhanced code when nodes change
useEffect(() => {
setEnhancedCode({})
}, [targetNodes])
useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
enhanceAbortRef.current?.abort()
}
}, [])
const tabs: { key: CodeTab; label: string }[] = [
{ key: 'react', label: 'React' },
{ key: 'vue', label: 'Vue' },
{ key: 'svelte', label: 'Svelte' },
{ key: 'html', label: 'HTML' },
{ key: 'swiftui', label: 'SwiftUI' },
{ key: 'compose', label: 'Compose' },
{ key: 'flutter', label: 'Flutter' },
{ key: 'react-native', label: 'RN' },
{ key: 'css-vars', label: t('code.cssVariables') },
]
const tabsScrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const updateScrollState = useCallback(() => {
const el = tabsScrollRef.current
if (!el) return
setCanScrollLeft(el.scrollLeft > 1)
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
}, [])
useEffect(() => {
const el = tabsScrollRef.current
if (!el) return
updateScrollState()
el.addEventListener('scroll', updateScrollState, { passive: true })
const ro = new ResizeObserver(updateScrollState)
ro.observe(el)
return () => {
el.removeEventListener('scroll', updateScrollState)
ro.disconnect()
}
}, [updateScrollState])
const scrollTabs = useCallback((dir: 'left' | 'right') => {
tabsScrollRef.current?.scrollBy({ left: dir === 'left' ? -80 : 80, behavior: 'smooth' })
}, [])
return highlightCode(generatedCode, lang)
}, [activeTab, generatedCode])
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Framework tabs + action buttons */}
<div className="flex items-center pl-1 pr-2 py-1 border-b border-border shrink-0 gap-0.5">
{canScrollLeft && (
<button type="button" onClick={() => scrollTabs('left')} className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground">
<ChevronLeft size={10} />
</button>
)}
<div ref={tabsScrollRef} className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-none px-1">
{tabs.map((tab) => (
<div className="flex flex-1 min-h-0 flex-col">
{/* Tab Bar */}
<div className="flex items-center border-b border-border px-2 shrink-0">
<div className="flex gap-1 overflow-x-auto py-1 scrollbar-none">
{FRAMEWORKS.map(fw => (
<button
key={tab.key}
key={fw}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
'text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 whitespace-nowrap',
activeTab === tab.key
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground',
'whitespace-nowrap rounded-md px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
activeTab === fw
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted',
)}
onClick={() => handleTabChange(fw)}
>
{tab.label}
{TAB_LABELS[fw]}
</button>
))}
</div>
{canScrollRight && (
<button type="button" onClick={() => scrollTabs('right')} className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground">
<ChevronRight size={10} />
</button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 flex flex-col">
{/* Empty State */}
{panelState === 'empty' && (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground" />
<div className="text-sm text-muted-foreground">
{nodeCount > 0
? `${nodeCount} node${nodeCount > 1 ? 's' : ''} selected`
: 'No nodes on page'}
</div>
<Button
onClick={handleGenerate}
disabled={nodeCount === 0}
size="sm"
>
<Sparkles className="mr-2 h-4 w-4" />
Generate {TAB_LABELS[activeTab]} Code
</Button>
{generateError && (
<div className="max-w-[260px] rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div className="font-medium">Generation failed</div>
<div className="mt-1 break-words">{generateError}</div>
</div>
)}
{selectionChanged && (
<div className="text-xs text-amber-500">
Selection changed since last generation
</div>
)}
</div>
)}
<div className="flex items-center gap-0.5 shrink-0">
{hasAI && activeTab !== 'css-vars' && (
<>
{enhancedCode[activeTab] && !isEnhancing && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleResetEnhance}
className="text-muted-foreground h-5 w-5"
>
<RotateCcw size={11} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('code.resetEnhance')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={isEnhancing ? handleCancelEnhance : handleEnhance}
className={cn(
'h-5 w-5',
isEnhancing && 'text-primary',
enhancedCode[activeTab] && !isEnhancing && 'text-primary',
)}
>
{isEnhancing ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent>{isEnhancing ? t('code.cancelEnhance') : t('code.aiEnhance')}</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleDownload}
className="h-5 w-5"
>
<Download size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('code.download')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
className="h-5 w-5"
>
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t('code.copied') : t('code.copyClipboard')}</TooltipContent>
</Tooltip>
</div>
</div>
{/* Code content */}
<div className="flex-1 overflow-auto p-2">
<pre className="text-[10px] leading-relaxed font-mono text-foreground/80 whitespace-pre-wrap break-all">
<code dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
</pre>
</div>
{/* Generating State */}
{panelState === 'generating' && (
<div className="flex flex-col gap-2 p-4">
{/* Planning */}
<ProgressItem
label="Planning"
status={planningStatus === 'running' ? 'running' : planningStatus === 'done' ? 'done' : planningStatus === 'failed' ? 'failed' : 'pending'}
error={planningError}
/>
{/* Footer info */}
<div className="h-5 flex items-center px-2 border-t border-border shrink-0">
<span className="text-[9px] text-muted-foreground">
{isEnhancing
? t('code.enhancing')
: enhancedCode[activeTab]
? t('code.enhanced')
: activeTab === 'css-vars'
? t('code.genCssVars')
: selectedIds.length > 0
? t('code.genSelected', { count: selectedIds.length })
: t('code.genDocument')}
</span>
{/* Chunks */}
{chunks.map(chunk => (
<ProgressItem
key={chunk.chunkId}
label={chunk.name}
status={chunk.status}
error={chunk.error}
onRetry={chunk.status === 'failed' ? () => handleRetryChunk(chunk.chunkId) : undefined}
/>
))}
{/* Assembly */}
{assemblyStatus !== 'idle' && (
<ProgressItem
label="Assembly"
status={assemblyStatus === 'running' ? 'running' : assemblyStatus === 'done' ? 'done' : 'failed'}
/>
)}
<Button variant="ghost" size="sm" onClick={handleCancel} className="mt-2 self-center">
Cancel
</Button>
</div>
)}
{/* Complete State */}
{panelState === 'complete' && (
<>
{isDegraded && (
<div className="flex items-center gap-2 bg-amber-500/10 px-3 py-2 text-xs text-amber-600 shrink-0">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Some chunks failed or degraded. Output may not compile.
</div>
)}
{selectionChanged && (
<div className="flex items-center justify-between bg-muted px-3 py-1.5 text-xs text-muted-foreground shrink-0">
<span>Selection changed</span>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={handleGenerate}>
Regenerate
</Button>
</div>
)}
<div className="flex-1 min-h-0 overflow-auto p-2">
<pre className="text-[10px] leading-relaxed font-mono text-foreground/80 whitespace-pre-wrap break-all">
<code dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
</pre>
</div>
<div className="flex items-center border-t border-border px-1 py-1 shrink-0 bg-card">
<Button variant="ghost" size="sm" className="h-7 flex-1 px-1 text-xs text-muted-foreground hover:text-foreground" onClick={handleCopy}>
{copied ? <Check className="mr-1 h-3 w-3 shrink-0" /> : <Copy className="mr-1 h-3 w-3 shrink-0" />}
<span className="truncate">{copied ? 'Copied' : 'Copy'}</span>
</Button>
<Button variant="ghost" size="sm" className="h-7 flex-1 px-1 text-xs text-muted-foreground hover:text-foreground" onClick={handleDownload}>
<Download className="mr-1 h-3 w-3 shrink-0" />
<span className="truncate">Download</span>
</Button>
<Button variant="ghost" size="sm" className="h-7 flex-1 px-1 text-xs text-muted-foreground hover:text-foreground" onClick={handleGenerate}>
<RefreshCw className="mr-1 h-3 w-3 shrink-0" />
<span className="truncate">Regenerate</span>
</Button>
</div>
</>
)}
</div>
</div>
)
}
// ── Progress Item Sub-Component ──
function ProgressItem({
label,
status,
error,
onRetry,
}: {
label: string
status: ChunkStatus | 'running' | 'done' | 'failed' | 'pending'
error?: string
onRetry?: () => void
}) {
const icons: Record<string, React.ReactNode> = {
pending: <div className="h-3.5 w-3.5 rounded-full border border-muted-foreground/30" />,
running: <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />,
done: <Check className="h-3.5 w-3.5 text-green-500" />,
degraded: <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />,
failed: <MinusCircle className="h-3.5 w-3.5 text-destructive" />,
skipped: <SkipForward className="h-3.5 w-3.5 text-muted-foreground" />,
}
const sublabels: Record<string, string> = {
degraded: 'generated without contract',
skipped: 'skipped (dependency failed)',
}
return (
<div className="flex items-start gap-2 text-sm">
<div className="mt-0.5">{icons[status]}</div>
<div className="flex-1">
<div className="font-medium">{label}</div>
{sublabels[status] && (
<div className="text-xs text-muted-foreground">{sublabels[status]}</div>
)}
{error && <div className="text-xs text-destructive">{error}</div>}
</div>
{onRetry && (
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={onRetry}>
Retry
</Button>
)}
</div>
)
}

View file

@ -0,0 +1,152 @@
import { useState } from 'react'
import {
ChevronRight,
ChevronDown,
Loader2,
Check,
X,
Undo2,
Wrench,
Search,
Plus,
Pencil,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AuthLevel } from '@zseven-w/agent'
export interface ToolCallBlockData {
id: string
name: string
args: unknown
level: AuthLevel
result?: { success: boolean; data?: unknown; error?: string }
status: 'pending' | 'running' | 'done' | 'error'
source?: string
}
interface ToolCallBlockProps {
block: ToolCallBlockData
onUndo?: (toolCallId: string) => void
}
const levelConfig: Record<
AuthLevel,
{
icon: typeof Search
defaultOpen: boolean
className: string
label: string
}
> = {
read: {
icon: Search,
defaultOpen: false,
className: 'text-muted-foreground',
label: 'Read',
},
create: {
icon: Plus,
defaultOpen: false,
className: 'text-foreground',
label: 'Create',
},
modify: {
icon: Pencil,
defaultOpen: true,
className: 'text-foreground',
label: 'Modify',
},
delete: {
icon: Trash2,
defaultOpen: true,
className: 'text-destructive',
label: 'Delete',
},
orchestrate: {
icon: Wrench,
defaultOpen: true,
className: 'text-primary',
label: 'Delegate',
},
}
export function ToolCallBlock({ block, onUndo }: ToolCallBlockProps) {
const config = levelConfig[block.level] ?? levelConfig.read
const [isOpen, setIsOpen] = useState(config.defaultOpen)
const Icon = config.icon
const ChevronIcon = isOpen ? ChevronDown : ChevronRight
return (
<div
className={cn(
'my-1 rounded-md border border-border bg-card/50 text-sm',
block.level === 'delete' && 'border-destructive/30 bg-destructive/5',
)}
>
<button
className={cn(
'flex w-full items-center gap-1.5 px-2 py-1 text-left',
config.className,
)}
onClick={() => setIsOpen(!isOpen)}
>
<ChevronIcon className="h-3 w-3 shrink-0 opacity-50" />
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium">{block.name}</span>
{block.source && block.source !== 'lead' && (
<span className="shrink-0 rounded bg-primary/10 px-1 py-0.5 text-[10px] text-primary">{block.source}</span>
)}
<span className="ml-auto flex items-center gap-1">
{block.status === 'running' && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{block.status === 'done' && block.result?.success && (
<Check className="h-3 w-3 text-green-500" />
)}
{(block.status === 'error' ||
(block.status === 'done' && !block.result?.success)) && (
<X className="h-3 w-3 text-destructive" />
)}
{block.level === 'delete' &&
block.status === 'done' &&
onUndo && (
<button
className="ml-1 rounded px-1.5 py-0.5 text-xs text-destructive hover:bg-destructive/10"
onClick={(e) => {
e.stopPropagation()
onUndo(block.id)
}}
>
<Undo2 className="inline h-3 w-3 mr-0.5" />
Undo
</button>
)}
</span>
</button>
{isOpen && (
<div className="border-t border-border px-2 py-1.5 text-xs text-muted-foreground">
<pre className="whitespace-pre-wrap break-all">
{JSON.stringify(block.args, null, 2)}
</pre>
{block.result && (
<div
className={cn(
'mt-1 pt-1 border-t border-border',
!block.result.success && 'text-destructive',
)}
>
{block.result.success ? (
<pre className="whitespace-pre-wrap break-all">
{JSON.stringify(block.result.data, null, 2)}
</pre>
) : (
<span>{block.result.error}</span>
)}
</div>
)}
</div>
)}
</div>
)
}

View file

@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import { BuiltinProvidersSection } from './builtin-provider-settings'
import type { AIProviderType, MCPTransportMode, GroupedModel } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
@ -323,12 +324,14 @@ function NavItem({ icon: IconComp, label, active, onClick }: {
</button>
)
}
/* ---------- Agents page ---------- */
function AgentsPage() {
const { t } = useTranslation()
return (
<div>
<div className="mb-6">
<BuiltinProvidersSection />
</div>
<h3 className="text-[15px] font-semibold text-foreground mb-4">{t('settings.agents')}</h3>
<div className="space-y-1">
<ProviderCard type="anthropic" />
@ -428,7 +431,8 @@ function McpPage(props: McpPageProps) {
{/* MCP Integrations */}
<div>
<h3 className="text-[15px] font-semibold text-foreground mb-1">{t('agents.mcpIntegrations')}</h3>
<p className="text-[11px] text-muted-foreground mb-3">{t('agents.mcpRestart')}</p>
<p className="text-[11px] text-muted-foreground mb-1">{t('agents.mcpRestart')}</p>
<p className="text-[11px] text-muted-foreground mb-3">{t('agents.mcpReinstallHint')}</p>
<div className="grid grid-cols-2 gap-1.5">
{mcpIntegrations.map((m) => (
<div
@ -628,7 +632,7 @@ export default function AgentSettingsDialog() {
/>
<div
ref={dialogRef}
className="relative bg-card rounded-xl border border-border w-[720px] h-[520px] overflow-hidden shadow-xl flex"
className="relative bg-card rounded-xl border border-border w-[720px] min-h-[520px] max-h-[720px] overflow-hidden shadow-xl flex"
>
{/* Sidebar */}
<div className="w-[200px] shrink-0 border-r border-border flex flex-col bg-card">

View file

@ -0,0 +1,587 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
Loader2, Check, Key, Plus, Trash2, Pencil, Eye, EyeOff, Search, ChevronDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import type { BuiltinProviderConfig, BuiltinProviderPreset } from '@/stores/agent-settings-store'
/* ---------- Provider Preset Config ---------- */
interface PresetRegion { baseURL: string }
interface PresetConfig {
label: string
type: 'anthropic' | 'openai-compat'
baseURL?: string
placeholder: string
modelPlaceholder: string
regions?: { cn: PresetRegion; global: PresetRegion }
}
const PROVIDER_PRESETS: Record<BuiltinProviderPreset, PresetConfig> = {
anthropic: {
label: 'Anthropic',
type: 'anthropic',
placeholder: 'sk-ant-...',
modelPlaceholder: 'claude-sonnet-4-6-20250916',
},
openai: {
label: 'OpenAI',
type: 'openai-compat',
baseURL: 'https://api.openai.com/v1',
placeholder: 'sk-...',
modelPlaceholder: 'gpt-5.4',
},
openrouter: {
label: 'OpenRouter',
type: 'openai-compat',
baseURL: 'https://openrouter.ai/api/v1',
placeholder: 'sk-or-...',
modelPlaceholder: 'anthropic/claude-sonnet-4.6',
},
deepseek: {
label: 'DeepSeek',
type: 'openai-compat',
baseURL: 'https://api.deepseek.com/v1',
placeholder: 'sk-...',
modelPlaceholder: 'deepseek-chat',
},
gemini: {
label: 'Google Gemini',
type: 'openai-compat',
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
placeholder: 'AIza...',
modelPlaceholder: 'gemini-3-flash-preview',
},
minimax: {
label: 'MiniMax',
type: 'openai-compat',
baseURL: 'https://api.minimaxi.com/v1',
placeholder: 'eyJ...',
modelPlaceholder: 'MiniMax-M2.7',
regions: {
cn: { baseURL: 'https://api.minimaxi.com/v1' },
global: { baseURL: 'https://api.minimax.io/v1' },
},
},
zhipu: {
label: '智谱 (Zhipu)',
type: 'openai-compat',
baseURL: 'https://open.bigmodel.cn/api/paas/v4',
placeholder: 'xxx.yyy',
modelPlaceholder: 'glm-5',
regions: {
cn: { baseURL: 'https://open.bigmodel.cn/api/paas/v4' },
global: { baseURL: 'https://open.z.ai/api/paas/v4' },
},
},
kimi: {
label: 'Kimi (Moonshot)',
type: 'openai-compat',
baseURL: 'https://api.moonshot.cn/v1',
placeholder: 'sk-...',
modelPlaceholder: 'kimi-k2.5',
regions: {
cn: { baseURL: 'https://api.moonshot.cn/v1' },
global: { baseURL: 'https://api.moonshot.ai/v1' },
},
},
bailian: {
label: 'Bailian (DashScope)',
type: 'openai-compat',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
placeholder: 'sk-...',
modelPlaceholder: 'qwen-plus',
regions: {
cn: { baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
global: { baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' },
},
},
doubao: {
label: 'DouBao Seed',
type: 'openai-compat',
baseURL: 'https://ark.cn-beijing.volces.com/api/v3',
placeholder: 'ARK API Key',
modelPlaceholder: 'doubao-seed-2.0-pro',
},
xiaomi: {
label: 'Xiaomi MiMo',
type: 'openai-compat',
baseURL: 'https://api.xiaomimimo.com/v1',
placeholder: 'API Key',
modelPlaceholder: 'mimo-v2-pro',
},
modelscope: {
label: 'ModelScope',
type: 'openai-compat',
baseURL: 'https://api-inference.modelscope.cn/v1',
placeholder: 'API Key',
modelPlaceholder: 'qwen-plus',
},
stepfun: {
label: 'StepFun',
type: 'openai-compat',
baseURL: 'https://api.stepfun.com/v1',
placeholder: 'API Key',
modelPlaceholder: 'step-3.5-flash',
regions: {
cn: { baseURL: 'https://api.stepfun.com/v1' },
global: { baseURL: 'https://api.stepfun.ai/v1' },
},
},
nvidia: {
label: 'NVIDIA NIM',
type: 'openai-compat',
baseURL: 'https://integrate.api.nvidia.com/v1',
placeholder: 'nvapi-...',
modelPlaceholder: 'nvidia/llama-3.1-nemotron-70b-instruct',
},
custom: {
label: 'Custom',
type: 'openai-compat',
placeholder: 'sk-...',
modelPlaceholder: 'model-name',
},
}
/** All known region URLs for reverse-lookup in inferPreset */
const REGION_URLS: Record<string, BuiltinProviderPreset> = Object.entries(PROVIDER_PRESETS).reduce(
(acc, [key, cfg]) => {
if (cfg.regions) {
acc[cfg.regions.cn.baseURL] = key as BuiltinProviderPreset
acc[cfg.regions.global.baseURL] = key as BuiltinProviderPreset
}
return acc
},
{} as Record<string, BuiltinProviderPreset>,
)
/** Hardcoded model lists for providers that don't expose /models endpoint */
const BUILTIN_MODEL_LISTS: Partial<Record<BuiltinProviderPreset, Array<{ id: string; name: string }>>> = {
anthropic: [
{ id: 'claude-opus-4-6-20250916', name: 'Claude Opus 4.6' },
{ id: 'claude-sonnet-4-6-20250916', name: 'Claude Sonnet 4.6' },
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
],
gemini: [
{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro' },
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash' },
{ id: 'gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash-Lite' },
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
],
minimax: [
{ id: 'MiniMax-M2.7', name: 'MiniMax M2.7' },
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed' },
{ id: 'MiniMax-M2.5', name: 'MiniMax M2.5' },
{ id: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 Highspeed' },
{ id: 'MiniMax-M2.1', name: 'MiniMax M2.1' },
{ id: 'MiniMax-M1', name: 'MiniMax M1' },
],
doubao: [
{ id: 'doubao-seed-2.0-pro', name: 'Doubao Seed 2.0 Pro' },
{ id: 'doubao-seed-2.0-lite', name: 'Doubao Seed 2.0 Lite' },
{ id: 'doubao-seed-2.0-code', name: 'Doubao Seed 2.0 Code' },
{ id: 'doubao-seed-code', name: 'Doubao Seed Code' },
],
}
/** Infer preset from an existing provider config (for editing) */
function inferPreset(config: BuiltinProviderConfig): BuiltinProviderPreset {
if (config.preset) return config.preset
if (config.type === 'anthropic') return 'anthropic'
const url = config.baseURL?.replace(/\/+$/, '') ?? ''
if (url === 'https://api.openai.com/v1') return 'openai'
if (url === 'https://openrouter.ai/api/v1') return 'openrouter'
if (url === 'https://api.deepseek.com/v1') return 'deepseek'
if (REGION_URLS[url]) return REGION_URLS[url]
return 'custom'
}
/** Infer region from a provider's baseURL */
function inferRegion(config: BuiltinProviderConfig): 'cn' | 'global' {
const preset = inferPreset(config)
const regions = PROVIDER_PRESETS[preset].regions
if (!regions) return 'cn'
const url = config.baseURL?.replace(/\/+$/, '') ?? ''
return url === regions.global.baseURL ? 'global' : 'cn'
}
/** Fetch model list from a provider via our server-side proxy */
async function fetchProviderModels(
baseURL: string,
apiKey?: string,
): Promise<{ models: Array<{ id: string; name: string }>; error?: string }> {
try {
const res = await fetch('/api/ai/provider-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseURL, apiKey }),
})
if (!res.ok) return { models: [], error: `Server error ${res.status}` }
return await res.json()
} catch {
return { models: [], error: 'Request failed' }
}
}
/* ---------- Model Search Dropdown ---------- */
function ModelSearchDropdown({
models,
onSelect,
onClose,
}: {
models: Array<{ id: string; name: string }>
onSelect: (model: { id: string; name: string }) => void
onClose: () => void
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState('')
const listRef = useRef<HTMLDivElement>(null)
const filtered = models.filter((m) => {
const q = filter.toLowerCase()
return m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)
})
useEffect(() => {
const handler = (e: MouseEvent) => {
if (listRef.current && !listRef.current.contains(e.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [onClose])
return (
<div
ref={listRef}
className="absolute bottom-full left-0 right-0 mb-1 rounded-md border border-border bg-popover shadow-md z-10 overflow-hidden"
>
<div className="p-1.5 border-b border-border">
<input
autoFocus
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t('builtin.filterModels')}
className="w-full h-7 px-2 text-[12px] bg-card text-foreground rounded border border-input focus:border-ring outline-none transition-colors"
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-3 py-4 text-center text-[11px] text-muted-foreground">
{t('builtin.noModels')}
</div>
)}
{filtered.map((m) => (
<button
key={m.id}
onClick={() => {
onSelect(m)
onClose()
}}
className="w-full text-left px-3 py-1.5 text-[12px] text-foreground hover:bg-secondary/50 transition-colors flex flex-col"
>
<span className="font-medium truncate">{m.name !== m.id ? m.name : m.id}</span>
{m.name !== m.id && (
<span className="text-[10px] text-muted-foreground font-mono truncate">{m.id}</span>
)}
</button>
))}
</div>
</div>
)
}
/* ---------- Builtin Provider Form ---------- */
export function BuiltinProviderForm({
initial,
onSave,
onCancel,
}: {
initial?: BuiltinProviderConfig
onSave: (data: Omit<BuiltinProviderConfig, 'id'>) => void
onCancel: () => void
}) {
const { t } = useTranslation()
const [preset, setPreset] = useState<BuiltinProviderPreset>(
initial ? inferPreset(initial) : 'anthropic',
)
const presetConfig = PROVIDER_PRESETS[preset]
const [region, setRegion] = useState<'cn' | 'global'>(
initial ? inferRegion(initial) : 'cn',
)
const [displayName, setDisplayName] = useState(initial?.displayName ?? '')
const [apiKey, setApiKey] = useState(initial?.apiKey ?? '')
const [modelName, setModelName] = useState(initial?.model ?? '')
const [baseURL, setBaseURL] = useState(
initial?.baseURL ?? presetConfig.baseURL ?? '',
)
const [showApiKey, setShowApiKey] = useState(false)
const [customApiFormat, setCustomApiFormat] = useState<'openai-compat' | 'anthropic'>(
initial?.type ?? 'openai-compat',
)
const [modelList, setModelList] = useState<Array<{ id: string; name: string }>>([])
const [showModelDropdown, setShowModelDropdown] = useState(false)
const [modelLoading, setModelLoading] = useState(false)
const [modelError, setModelError] = useState<string | null>(null)
const handlePresetChange = useCallback(
(newPreset: BuiltinProviderPreset) => {
setPreset(newPreset)
const cfg = PROVIDER_PRESETS[newPreset]
if (!displayName.trim() || displayName === PROVIDER_PRESETS[preset].label) {
setDisplayName(cfg.label)
}
setRegion('cn')
setBaseURL(cfg.regions?.cn.baseURL ?? cfg.baseURL ?? '')
setModelList([])
setShowModelDropdown(false)
setModelError(null)
},
[displayName, preset],
)
const handleRegionChange = useCallback(
(newRegion: 'cn' | 'global') => {
setRegion(newRegion)
const regions = presetConfig.regions
if (regions) {
setBaseURL(regions[newRegion].baseURL)
setModelList([])
setShowModelDropdown(false)
setModelError(null)
}
},
[presetConfig],
)
const handleFetchModels = useCallback(async () => {
const builtinList = BUILTIN_MODEL_LISTS[preset]
if (builtinList) {
setModelList(builtinList)
setShowModelDropdown(true)
return
}
const cfg = PROVIDER_PRESETS[preset]
const url = preset === 'custom' ? baseURL.trim() : (cfg.regions ? cfg.regions[region].baseURL : cfg.baseURL)
if (!url) { setModelError(t('builtin.searchError')); return }
setModelLoading(true)
setModelError(null)
const result = await fetchProviderModels(url, apiKey.trim() || undefined)
setModelLoading(false)
if (result.error) {
setModelError(result.error)
if (result.models.length > 0) { setModelList(result.models); setShowModelDropdown(true) }
} else {
setModelList(result.models)
setShowModelDropdown(true)
}
}, [preset, baseURL, apiKey])
const handleModelSelect = useCallback((model: { id: string; name: string }) => {
setModelName(model.id)
setShowModelDropdown(false)
}, [])
const isBaseURLLocked = preset !== 'custom'
const effectiveType = preset === 'custom' ? customApiFormat : presetConfig.type
const showBaseURL = effectiveType === 'openai-compat' || preset === 'custom'
const canSave = displayName.trim().length > 0 && apiKey.trim().length > 0 && modelName.trim().length > 0
return (
<div className="space-y-3 rounded-lg border border-border bg-secondary/20 p-3.5">
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{t('builtin.displayName')}</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={t('builtin.displayNamePlaceholder')}
className="w-full h-8 px-2.5 text-[13px] bg-card text-foreground rounded-md border border-input focus:border-ring outline-none transition-colors" />
</div>
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{t('builtin.provider')}</label>
<select value={preset} onChange={(e) => handlePresetChange(e.target.value as BuiltinProviderPreset)}
className="w-full h-8 px-2 text-[13px] bg-card text-foreground rounded-md border border-input focus:border-ring outline-none transition-colors">
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
<option value="gemini">Google Gemini</option>
<option value="minimax">MiniMax</option>
<option value="zhipu"> (Zhipu)</option>
<option value="kimi">Kimi (Moonshot)</option>
<option value="bailian">Bailian (DashScope)</option>
<option value="doubao">DouBao Seed</option>
<option value="xiaomi">Xiaomi MiMo</option>
<option value="modelscope">ModelScope</option>
<option value="stepfun">StepFun</option>
<option value="nvidia">NVIDIA NIM</option>
<option value="custom">{t('builtin.custom')}</option>
</select>
</div>
{presetConfig.regions && (
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{t('builtin.region')}</label>
<div className="flex gap-1">
{(['cn', 'global'] as const).map((r) => (
<button key={r} type="button" onClick={() => handleRegionChange(r)}
className={cn('flex-1 h-7 text-[11px] rounded-md border transition-colors',
region === r ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-input hover:bg-accent')}>
{t(`builtin.region${r === 'cn' ? 'China' : 'Global'}`)}
</button>
))}
</div>
</div>
)}
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{t('builtin.apiKey')}</label>
<div className="relative">
<input type={showApiKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={presetConfig.placeholder}
className="w-full h-8 px-2.5 pr-8 text-[13px] bg-card text-foreground rounded-md border border-input focus:border-ring outline-none transition-colors font-mono" />
<button type="button" onClick={() => setShowApiKey((v) => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{showApiKey ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
</div>
</div>
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{t('builtin.model')}</label>
<div className="relative">
<input value={modelName} onChange={(e) => setModelName(e.target.value)} placeholder={presetConfig.modelPlaceholder}
className="w-full h-8 px-2.5 pr-16 text-[13px] bg-card text-foreground rounded-md border border-input focus:border-ring outline-none transition-colors font-mono" />
<button type="button" onClick={handleFetchModels} disabled={modelLoading} title={t('builtin.searchModels')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 h-6 px-1.5 rounded flex items-center gap-1 text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors disabled:opacity-50">
{modelLoading ? <Loader2 size={12} className="animate-spin" /> : <><Search size={12} /><ChevronDown size={10} /></>}
</button>
{showModelDropdown && modelList.length > 0 && (
<ModelSearchDropdown models={modelList} onSelect={handleModelSelect} onClose={() => setShowModelDropdown(false)} />
)}
</div>
{modelError && <p className="text-[10px] text-destructive mt-1">{modelError}</p>}
</div>
{preset === 'custom' && (
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{t('builtin.apiFormat')}</label>
<div className="flex gap-1">
{(['openai-compat', 'anthropic'] as const).map((fmt) => (
<button key={fmt} type="button" onClick={() => setCustomApiFormat(fmt)}
className={cn('flex-1 h-7 text-[11px] rounded-md border transition-colors',
customApiFormat === fmt ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-input hover:bg-accent')}>
{fmt === 'openai-compat' ? t('builtin.openaiCompat') : 'Anthropic'}
</button>
))}
</div>
</div>
)}
{showBaseURL && (
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{isBaseURLLocked ? t('builtin.baseUrl') : t('builtin.baseUrlRequired')}</label>
<input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder={t('builtin.baseUrlPlaceholder')} readOnly={isBaseURLLocked}
className={cn('w-full h-8 px-2.5 text-[13px] bg-card text-foreground rounded-md border border-input focus:border-ring outline-none transition-colors font-mono', isBaseURLLocked && 'opacity-60 cursor-default')} />
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={onCancel} className="h-7 px-3 text-[11px]">{t('common.cancel')}</Button>
<Button size="sm" onClick={() => onSave({
displayName: displayName.trim(), type: effectiveType, apiKey: apiKey.trim(), model: modelName.trim(), preset,
...(showBaseURL && baseURL.trim() ? { baseURL: baseURL.trim() } : {}), enabled: initial?.enabled ?? true,
})} disabled={!canSave} className="h-7 px-3 text-[11px]">{initial ? t('common.save') : t('builtin.add')}</Button>
</div>
</div>
)
}
/* ---------- Builtin Provider Card ---------- */
export function BuiltinProviderCard({ provider }: { provider: BuiltinProviderConfig }) {
const { t } = useTranslation()
const update = useAgentSettingsStore((s) => s.updateBuiltinProvider)
const remove = useAgentSettingsStore((s) => s.removeBuiltinProvider)
const persist = useAgentSettingsStore((s) => s.persist)
const [editing, setEditing] = useState(false)
const handleToggle = useCallback((enabled: boolean) => { update(provider.id, { enabled }); persist() }, [provider.id, update, persist])
const handleRemove = useCallback(() => { remove(provider.id); persist() }, [provider.id, remove, persist])
const handleSave = useCallback((data: Omit<BuiltinProviderConfig, 'id'>) => { update(provider.id, data); persist(); setEditing(false) }, [provider.id, update, persist])
if (editing) {
return <BuiltinProviderForm initial={provider} onSave={handleSave} onCancel={() => setEditing(false)} />
}
const masked = provider.apiKey.length > 12 ? provider.apiKey.slice(0, 7) + '***' + provider.apiKey.slice(-3) : '***'
return (
<div className="group">
<div className={cn('flex items-center gap-3 px-3.5 py-2.5 rounded-lg border transition-colors',
provider.enabled ? 'bg-secondary/30 border-border' : 'border-transparent hover:bg-secondary/20')}>
<div className={cn('w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors',
provider.enabled ? 'bg-foreground/8 text-foreground' : 'bg-secondary text-muted-foreground')}>
<Key size={18} />
</div>
<div className="flex-1 min-w-0">
<span className="text-[13px] font-medium text-foreground leading-tight block">{provider.displayName}</span>
<span className="text-[11px] text-muted-foreground leading-tight mt-0.5 block">{provider.model} &middot; {masked}</span>
{provider.enabled && (
<span className="text-[11px] text-green-500 leading-tight flex items-center gap-1 mt-0.5"><Check size={10} strokeWidth={2.5} />{t('builtin.ready')}</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Switch checked={provider.enabled} onCheckedChange={handleToggle} className="mr-1" />
<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>
)
}
/* ---------- Builtin Providers Section (used in AgentsPage) ---------- */
export function BuiltinProvidersSection() {
const { t } = useTranslation()
const builtinProviders = useAgentSettingsStore((s) => s.builtinProviders)
const addBuiltinProvider = useAgentSettingsStore((s) => s.addBuiltinProvider)
const persist = useAgentSettingsStore((s) => s.persist)
const [showForm, setShowForm] = useState(false)
const handleAdd = useCallback(
(data: Omit<BuiltinProviderConfig, 'id'>) => {
addBuiltinProvider(data)
persist()
setShowForm(false)
},
[addBuiltinProvider, persist],
)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t('builtin.title')}</h3>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
<Plus size={12} /> {t('builtin.addProvider')}
</button>
)}
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('builtin.description')}
</p>
{showForm && <BuiltinProviderForm onSave={handleAdd} onCancel={() => setShowForm(false)} />}
{builtinProviders.map((bp) => (
<BuiltinProviderCard key={bp.id} provider={bp} />
))}
{!showForm && builtinProviders.length === 0 && (
<div className="text-center py-6 text-[11px] text-muted-foreground">
{t('builtin.empty')}
</div>
)}
</div>
)
}

View file

@ -54,10 +54,10 @@ export function useMcpSync() {
clientIdRef.current = data.clientId
// Push current document so MCP can read it immediately
pushDocumentToServer(data.clientId)
} else if (data.type === 'document:update') {
} else if (data.type === 'document:update' || data.type === 'document:init') {
const doc = data.document as PenDocument
const childCount = doc.pages?.[0]?.children?.length ?? doc.children?.length ?? 0
console.log('[mcp-sync] Received document:update, top-level children:', childCount)
console.log(`[mcp-sync] Received ${data.type}, top-level children:`, childCount)
// Suppress push-back for a short window — applyExternalDocument
// may trigger multiple cascading setState calls.
skipPushUntilRef.current = Date.now() + 200
@ -80,12 +80,28 @@ export function useMcpSync() {
}
}
connect()
// Clear stale server cache from previous session before connecting.
// This prevents document:init from echoing back an old document and
// falsely marking the editor as dirty after a page refresh or file open.
fetch(`${baseUrl}/api/mcp/sync-reset`, { method: 'POST' })
.catch(() => {})
.finally(() => {
if (!disposed) connect()
})
// Push local document changes to Nitro (debounced)
const unsubDoc = useDocumentStore.subscribe(() => {
// Push local document changes to Nitro (debounced).
// On loadDocument/newDocument (isDirty transitions to false), push
// immediately so the server cache is replaced without waiting 2s.
const unsubDoc = useDocumentStore.subscribe((state, prevState) => {
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)
return
}
pushTimerRef.current = setTimeout(() => {
pushDocumentToServer(clientIdRef.current)
}, PUSH_DEBOUNCE_MS)

View file

@ -362,6 +362,8 @@ const de: TranslationKeys = {
'agents.port': 'Port',
'agents.mcpRestart':
'MCP-Integrationen werden nach einem Neustart des Terminals wirksam.',
'agents.mcpReinstallHint':
'Bitte installieren Sie die MCP-Integrationen nach einem OpenPencil-Upgrade erneut, um die Kompatibilität sicherzustellen.',
'agents.modelCount': '{{count}} Modell(e)',
'agents.connectionFailed': 'Verbindung fehlgeschlagen',
'agents.serverError': 'Serverfehler {{status}}',
@ -404,6 +406,40 @@ const de: TranslationKeys = {
'settings.systemDesktopOnly': 'Systemeinstellungen sind in der Desktop-App verfügbar.',
'settings.envHint': 'Sie können zusätzliche Umgebungsvariablen in {{path}} festlegen.',
// ── Builtin Providers ──
'builtin.title': 'Integrierte Anbieter',
'builtin.description': 'API-Schlüssel direkt konfigurieren — keine CLI-Tools erforderlich.',
'builtin.addProvider': 'Anbieter hinzufügen',
'builtin.empty': 'Noch keine integrierten Anbieter konfiguriert.',
'builtin.displayName': 'Anzeigename',
'builtin.displayNamePlaceholder': 'z.B. Mein Anthropic-Schlüssel',
'builtin.provider': 'Anbieter',
'builtin.region': 'Region',
'builtin.regionChina': 'China',
'builtin.regionGlobal': 'Global',
'builtin.apiKey': 'API Key',
'builtin.model': 'Modell',
'builtin.searchModels': 'Verfügbare Modelle durchsuchen',
'builtin.filterModels': 'Modelle filtern...',
'builtin.noModels': 'Keine Modelle gefunden',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (erforderlich)',
'builtin.apiFormat': 'API-Format',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Bereit',
'builtin.add': 'Hinzufügen',
'builtin.searchError': 'Base URL ist erforderlich, um Modelle zu durchsuchen',
'builtin.custom': 'Benutzerdefiniert',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'über {{name}} API Key',
'builtin.errorProviderNotFound': 'Integrierter Anbieter nicht gefunden. Bitte überprüfen Sie Ihre Einstellungen.',
'builtin.errorApiKeyEmpty': 'API Key ist leer. Bitte fügen Sie Ihren API Key in den Einstellungen hinzu.',
'builtin.parallelAgents': 'Parallele Sub-Agenten: {{count}}x (klicken zum Wechseln)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Wählen Sie ein Modell für die Designgenerierung. Wenn gesetzt, werden Designaufgaben automatisch an einen spezialisierten Agenten mit diesem Modell delegiert.',
'builtin.teamDesignModel': 'Design-Modell',
'builtin.teamSelectModel': 'Keins (Einzelagent)',
// ── Figma Import ──
'figma.title': 'Aus Figma importieren',
'figma.dropFile': '.fig-Datei hier ablegen',

View file

@ -358,6 +358,8 @@ const en = {
'agents.port': 'Port',
'agents.mcpRestart':
'MCP integrations will take effect after restarting the terminal.',
'agents.mcpReinstallHint':
'After upgrading OpenPencil, please reinstall MCP integrations to ensure compatibility.',
'agents.modelCount': '{{count}} model(s)',
'agents.connectionFailed': 'Connection failed',
'agents.serverError': 'Server error {{status}}',
@ -400,6 +402,40 @@ const en = {
'settings.systemDesktopOnly': 'System settings are available in the desktop app.',
'settings.envHint': 'You can set additional environment variables in {{path}}.',
// ── Builtin Providers ──
'builtin.title': 'Built-in Providers',
'builtin.description': 'Configure API keys directly — no CLI tools required.',
'builtin.addProvider': 'Add Provider',
'builtin.empty': 'No built-in providers configured yet.',
'builtin.displayName': 'Display Name',
'builtin.displayNamePlaceholder': 'e.g. My Anthropic Key',
'builtin.provider': 'Provider',
'builtin.region': 'Region',
'builtin.regionChina': 'China',
'builtin.regionGlobal': 'Global',
'builtin.apiKey': 'API Key',
'builtin.model': 'Model',
'builtin.searchModels': 'Search available models',
'builtin.filterModels': 'Filter models...',
'builtin.noModels': 'No models found',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (required)',
'builtin.apiFormat': 'API Format',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Ready',
'builtin.add': 'Add',
'builtin.searchError': 'Base URL is required to search models',
'builtin.custom': 'Custom',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'via {{name}} API Key',
'builtin.errorProviderNotFound': 'Built-in provider not found. Please check your settings.',
'builtin.errorApiKeyEmpty': 'API key is empty. Please add your API key in settings.',
'builtin.parallelAgents': 'Parallel sub-agents: {{count}}x (click to cycle)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Select a model for design generation. When set, design tasks are automatically delegated to a specialist agent using this model.',
'builtin.teamDesignModel': 'Design Model',
'builtin.teamSelectModel': 'None (single agent)',
// ── Figma Import ──
'figma.title': 'Import from Figma',
'figma.dropFile': 'Drop a .fig file here',

View file

@ -367,6 +367,8 @@ const es: TranslationKeys = {
'agents.port': 'Puerto',
'agents.mcpRestart':
'Las integraciones MCP se aplicarán tras reiniciar la terminal.',
'agents.mcpReinstallHint':
'Después de actualizar OpenPencil, reinstale las integraciones MCP para garantizar la compatibilidad.',
'agents.modelCount': '{{count}} modelo(s)',
'agents.connectionFailed': 'Error de conexión',
'agents.serverError': 'Error del servidor {{status}}',
@ -409,6 +411,40 @@ const es: TranslationKeys = {
'settings.systemDesktopOnly': 'La configuración del sistema está disponible en la aplicación de escritorio.',
'settings.envHint': 'Puedes establecer variables de entorno adicionales en {{path}}.',
// ── Builtin Providers ──
'builtin.title': 'Proveedores integrados',
'builtin.description': 'Configure las claves API directamente — sin herramientas CLI necesarias.',
'builtin.addProvider': 'Agregar proveedor',
'builtin.empty': 'Aún no hay proveedores integrados configurados.',
'builtin.displayName': 'Nombre visible',
'builtin.displayNamePlaceholder': 'ej. Mi clave de Anthropic',
'builtin.provider': 'Proveedor',
'builtin.region': 'Región',
'builtin.regionChina': 'China',
'builtin.regionGlobal': 'Global',
'builtin.apiKey': 'API Key',
'builtin.model': 'Modelo',
'builtin.searchModels': 'Buscar modelos disponibles',
'builtin.filterModels': 'Filtrar modelos...',
'builtin.noModels': 'No se encontraron modelos',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (obligatorio)',
'builtin.apiFormat': 'Formato de API',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Listo',
'builtin.add': 'Agregar',
'builtin.searchError': 'Se requiere Base URL para buscar modelos',
'builtin.custom': 'Personalizado',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'mediante API Key de {{name}}',
'builtin.errorProviderNotFound': 'Proveedor integrado no encontrado. Por favor, revise su configuración.',
'builtin.errorApiKeyEmpty': 'La API key está vacía. Por favor, agregue su API key en la configuración.',
'builtin.parallelAgents': 'Sub-agentes en paralelo: {{count}}x (clic para cambiar)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Selecciona un modelo para la generación de diseño. Una vez configurado, las tareas de diseño se delegan automáticamente a un agente especializado que usa este modelo.',
'builtin.teamDesignModel': 'Modelo de diseño',
'builtin.teamSelectModel': 'Ninguno (agente único)',
// ── Figma Import ──
'figma.title': 'Importar desde Figma',
'figma.dropFile': 'Suelte un archivo .fig aquí',

View file

@ -365,6 +365,8 @@ const fr: TranslationKeys = {
'agents.port': 'Port',
'agents.mcpRestart':
'Les intégrations MCP prendront effet après le redémarrage du terminal.',
'agents.mcpReinstallHint':
'Après la mise à jour d\'OpenPencil, veuillez réinstaller les intégrations MCP pour assurer la compatibilité.',
'agents.modelCount': '{{count}} modèle(s)',
'agents.connectionFailed': 'Échec de la connexion',
'agents.serverError': 'Erreur serveur {{status}}',
@ -407,6 +409,40 @@ const fr: TranslationKeys = {
'settings.systemDesktopOnly': 'Les paramètres système sont disponibles dans l\'application de bureau.',
'settings.envHint': 'Vous pouvez définir des variables d\'environnement supplémentaires dans {{path}}.',
// ── Builtin Providers ──
'builtin.title': 'Fournisseurs intégrés',
'builtin.description': 'Configurez les clés API directement — aucun outil CLI requis.',
'builtin.addProvider': 'Ajouter un fournisseur',
'builtin.empty': 'Aucun fournisseur intégré configuré.',
'builtin.displayName': 'Nom d\'affichage',
'builtin.displayNamePlaceholder': 'ex. Ma clé Anthropic',
'builtin.provider': 'Fournisseur',
'builtin.region': 'Région',
'builtin.regionChina': 'Chine',
'builtin.regionGlobal': 'Mondial',
'builtin.apiKey': 'API Key',
'builtin.model': 'Modèle',
'builtin.searchModels': 'Rechercher les modèles disponibles',
'builtin.filterModels': 'Filtrer les modèles...',
'builtin.noModels': 'Aucun modèle trouvé',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (requis)',
'builtin.apiFormat': 'Format API',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Prêt',
'builtin.add': 'Ajouter',
'builtin.searchError': 'Le Base URL est requis pour rechercher des modèles',
'builtin.custom': 'Personnalisé',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'via API Key {{name}}',
'builtin.errorProviderNotFound': 'Fournisseur intégré introuvable. Veuillez vérifier vos paramètres.',
'builtin.errorApiKeyEmpty': 'La clé API est vide. Veuillez ajouter votre clé API dans les paramètres.',
'builtin.parallelAgents': 'Sous-agents parallèles : {{count}}x (cliquez pour changer)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Sélectionnez un modèle pour la génération de design. Une fois défini, les tâches de design sont automatiquement déléguées à un agent spécialisé utilisant ce modèle.',
'builtin.teamDesignModel': 'Modèle de design',
'builtin.teamSelectModel': 'Aucun (agent unique)',
// ── Figma Import ──
'figma.title': 'Importer depuis Figma',
'figma.dropFile': 'Déposez un fichier .fig ici',

View file

@ -360,6 +360,8 @@ const hi: TranslationKeys = {
'agents.port': 'पोर्ट',
'agents.mcpRestart':
'MCP इंटीग्रेशन टर्मिनल पुनः आरंभ करने के बाद प्रभावी होंगे।',
'agents.mcpReinstallHint':
'OpenPencil अपग्रेड करने के बाद, संगतता सुनिश्चित करने के लिए कृपया MCP इंटीग्रेशन पुनः इंस्टॉल करें।',
'agents.modelCount': '{{count}} मॉडल',
'agents.connectionFailed': 'कनेक्शन विफल',
'agents.serverError': 'सर्वर त्रुटि {{status}}',
@ -402,6 +404,40 @@ const hi: TranslationKeys = {
'settings.systemDesktopOnly': 'सिस्टम सेटिंग्स डेस्कटॉप ऐप में उपलब्ध हैं।',
'settings.envHint': 'आप {{path}} में अतिरिक्त पर्यावरण चर सेट कर सकते हैं।',
// ── Builtin Providers ──
'builtin.title': 'अंतर्निहित प्रदाता',
'builtin.description': 'API कुंजियाँ सीधे कॉन्फ़िगर करें — किसी CLI टूल की आवश्यकता नहीं।',
'builtin.addProvider': 'प्रदाता जोड़ें',
'builtin.empty': 'अभी तक कोई अंतर्निहित प्रदाता कॉन्फ़िगर नहीं किया गया।',
'builtin.displayName': 'प्रदर्शन नाम',
'builtin.displayNamePlaceholder': 'उदा. मेरी Anthropic कुंजी',
'builtin.provider': 'प्रदाता',
'builtin.region': 'क्षेत्र',
'builtin.regionChina': 'चीन',
'builtin.regionGlobal': 'वैश्विक',
'builtin.apiKey': 'API Key',
'builtin.model': 'मॉडल',
'builtin.searchModels': 'उपलब्ध मॉडल खोजें',
'builtin.filterModels': 'मॉडल फ़िल्टर करें...',
'builtin.noModels': 'कोई मॉडल नहीं मिला',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (आवश्यक)',
'builtin.apiFormat': 'API फ़ॉर्मेट',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'तैयार',
'builtin.add': 'जोड़ें',
'builtin.searchError': 'मॉडल खोजने के लिए Base URL आवश्यक है',
'builtin.custom': 'कस्टम',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': '{{name}} API Key के माध्यम से',
'builtin.errorProviderNotFound': 'अंतर्निहित प्रदाता नहीं मिला। कृपया अपनी सेटिंग्स जाँचें।',
'builtin.errorApiKeyEmpty': 'API key खाली है। कृपया सेटिंग्स में अपनी API key जोड़ें।',
'builtin.parallelAgents': 'समानांतर सब-एजेंट: {{count}}x (बदलने के लिए क्लिक करें)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'डिज़ाइन जनरेशन के लिए एक मॉडल चुनें। सेट करने पर, डिज़ाइन कार्य स्वचालित रूप से इस मॉडल का उपयोग करने वाले विशेषज्ञ एजेंट को सौंपे जाते हैं।',
'builtin.teamDesignModel': 'डिज़ाइन मॉडल',
'builtin.teamSelectModel': 'कोई नहीं (एकल एजेंट)',
// ── Figma Import ──
'figma.title': 'Figma से आयात करें',
'figma.dropFile': 'यहाँ .fig फ़ाइल ड्रॉप करें',

View file

@ -360,6 +360,8 @@ const id: TranslationKeys = {
'agents.port': 'Port',
'agents.mcpRestart':
'Integrasi MCP akan berlaku setelah terminal dimulai ulang.',
'agents.mcpReinstallHint':
'Setelah memperbarui OpenPencil, silakan instal ulang integrasi MCP untuk memastikan kompatibilitas.',
'agents.modelCount': '{{count}} model',
'agents.connectionFailed': 'Koneksi gagal',
'agents.serverError': 'Kesalahan server {{status}}',
@ -402,6 +404,40 @@ const id: TranslationKeys = {
'settings.systemDesktopOnly': 'Pengaturan sistem tersedia di aplikasi desktop.',
'settings.envHint': 'Anda dapat mengatur variabel lingkungan tambahan di {{path}}.',
// ── Builtin Providers ──
'builtin.title': 'Penyedia Bawaan',
'builtin.description': 'Konfigurasikan API Key secara langsung — tanpa perlu alat CLI.',
'builtin.addProvider': 'Tambah Penyedia',
'builtin.empty': 'Belum ada penyedia bawaan yang dikonfigurasi.',
'builtin.displayName': 'Nama Tampilan',
'builtin.displayNamePlaceholder': 'cth. Kunci Anthropic Saya',
'builtin.provider': 'Penyedia',
'builtin.region': 'Wilayah',
'builtin.regionChina': 'Tiongkok',
'builtin.regionGlobal': 'Global',
'builtin.apiKey': 'API Key',
'builtin.model': 'Model',
'builtin.searchModels': 'Cari model yang tersedia',
'builtin.filterModels': 'Filter model...',
'builtin.noModels': 'Model tidak ditemukan',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (wajib)',
'builtin.apiFormat': 'Format API',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Siap',
'builtin.add': 'Tambah',
'builtin.searchError': 'Base URL diperlukan untuk mencari model',
'builtin.custom': 'Kustom',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'melalui API Key {{name}}',
'builtin.errorProviderNotFound': 'Penyedia bawaan tidak ditemukan. Silakan periksa pengaturan Anda.',
'builtin.errorApiKeyEmpty': 'API Key kosong. Silakan tambahkan API Key di pengaturan.',
'builtin.parallelAgents': 'Sub-agen paralel: {{count}}x (klik untuk berganti)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Pilih model untuk pembuatan desain. Jika diatur, tugas desain akan otomatis didelegasikan ke agen spesialis yang menggunakan model ini.',
'builtin.teamDesignModel': 'Model Desain',
'builtin.teamSelectModel': 'Tidak ada (agen tunggal)',
// ── Figma Import ──
'figma.title': 'Impor dari Figma',
'figma.dropFile': 'Letakkan file .fig di sini',

View file

@ -363,6 +363,8 @@ const ja: TranslationKeys = {
'agents.port': 'ポート',
'agents.mcpRestart':
'MCP 連携はターミナルの再起動後に有効になります。',
'agents.mcpReinstallHint':
'OpenPencil のバージョンアップ後、互換性を確保するため MCP 統合を再インストールしてください。',
'agents.modelCount': '{{count}} 個のモデル',
'agents.connectionFailed': '接続に失敗しました',
'agents.serverError': 'サーバーエラー {{status}}',
@ -405,6 +407,40 @@ const ja: TranslationKeys = {
'settings.systemDesktopOnly': 'システム設定はデスクトップアプリで利用できます。',
'settings.envHint': '{{path}} で追加の環境変数を設定できます。',
// ── Builtin Providers ──
'builtin.title': '組み込みプロバイダー',
'builtin.description': 'API キーを直接設定 — CLI ツール不要。',
'builtin.addProvider': 'プロバイダーを追加',
'builtin.empty': '組み込みプロバイダーはまだ設定されていません。',
'builtin.displayName': '表示名',
'builtin.displayNamePlaceholder': '例My Anthropic Key',
'builtin.provider': 'プロバイダー',
'builtin.region': 'リージョン',
'builtin.regionChina': '中国',
'builtin.regionGlobal': 'グローバル',
'builtin.apiKey': 'API Key',
'builtin.model': 'モデル',
'builtin.searchModels': '利用可能なモデルを検索',
'builtin.filterModels': 'モデルを絞り込み...',
'builtin.noModels': 'モデルが見つかりません',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL必須',
'builtin.apiFormat': 'API フォーマット',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': '準備完了',
'builtin.add': '追加',
'builtin.searchError': 'モデルを検索するには Base URL が必要です',
'builtin.custom': 'カスタム',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': '{{name}} API Key 経由',
'builtin.errorProviderNotFound': '組み込みプロバイダーが見つかりません。設定を確認してください。',
'builtin.errorApiKeyEmpty': 'API キーが空です。設定で API キーを追加してください。',
'builtin.parallelAgents': '並列サブエージェント:{{count}}xクリックで切替',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'デザイン生成用のモデルを選択します。設定すると、デザインタスクはこのモデルを使用する専門エージェントに自動的に委任されます。',
'builtin.teamDesignModel': 'デザインモデル',
'builtin.teamSelectModel': 'なし(シングルエージェント)',
// ── Figma Import ──
'figma.title': 'Figma からインポート',
'figma.dropFile': '.fig ファイルをここにドロップ',

View file

@ -360,6 +360,8 @@ const ko: TranslationKeys = {
'agents.port': '포트',
'agents.mcpRestart':
'MCP 연동은 터미널을 재시작한 후 적용됩니다.',
'agents.mcpReinstallHint':
'OpenPencil 버전 업그레이드 후 호환성을 위해 MCP 통합을 다시 설치해 주세요.',
'agents.modelCount': '모델 {{count}}개',
'agents.connectionFailed': '연결 실패',
'agents.serverError': '서버 오류 {{status}}',
@ -402,6 +404,40 @@ const ko: TranslationKeys = {
'settings.systemDesktopOnly': '시스템 설정은 데스크톱 앱에서 사용할 수 있습니다.',
'settings.envHint': '{{path}}에서 추가 환경 변수를 설정할 수 있습니다.',
// ── Builtin Providers ──
'builtin.title': '내장 제공자',
'builtin.description': 'API 키를 직접 설정 — CLI 도구 불필요.',
'builtin.addProvider': '제공자 추가',
'builtin.empty': '아직 내장 제공자가 설정되지 않았습니다.',
'builtin.displayName': '표시 이름',
'builtin.displayNamePlaceholder': '예: My Anthropic Key',
'builtin.provider': '제공자',
'builtin.region': '리전',
'builtin.regionChina': '중국',
'builtin.regionGlobal': '글로벌',
'builtin.apiKey': 'API Key',
'builtin.model': '모델',
'builtin.searchModels': '사용 가능한 모델 검색',
'builtin.filterModels': '모델 필터...',
'builtin.noModels': '모델을 찾을 수 없습니다',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (필수)',
'builtin.apiFormat': 'API 형식',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': '준비 완료',
'builtin.add': '추가',
'builtin.searchError': '모델을 검색하려면 Base URL이 필요합니다',
'builtin.custom': '사용자 정의',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': '{{name}} API Key를 통해',
'builtin.errorProviderNotFound': '내장 제공자를 찾을 수 없습니다. 설정을 확인해 주세요.',
'builtin.errorApiKeyEmpty': 'API 키가 비어 있습니다. 설정에서 API 키를 추가해 주세요.',
'builtin.parallelAgents': '병렬 하위 에이전트: {{count}}x (클릭하여 전환)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': '디자인 생성용 모델을 선택하세요. 설정하면 디자인 작업이 이 모델을 사용하는 전문 에이전트에 자동으로 위임됩니다.',
'builtin.teamDesignModel': '디자인 모델',
'builtin.teamSelectModel': '없음 (단일 에이전트)',
// ── Figma Import ──
'figma.title': 'Figma에서 가져오기',
'figma.dropFile': '.fig 파일을 여기에 놓으세요',

View file

@ -362,6 +362,8 @@ const pt: TranslationKeys = {
'agents.port': 'Porta',
'agents.mcpRestart':
'As integrações MCP entrarão em vigor após reiniciar o terminal.',
'agents.mcpReinstallHint':
'Após atualizar o OpenPencil, reinstale as integrações MCP para garantir a compatibilidade.',
'agents.modelCount': '{{count}} modelo(s)',
'agents.connectionFailed': 'Falha na conexão',
'agents.serverError': 'Erro do servidor {{status}}',
@ -404,6 +406,40 @@ const pt: TranslationKeys = {
'settings.systemDesktopOnly': 'As configurações do sistema estão disponíveis no aplicativo de desktop.',
'settings.envHint': 'Você pode definir variáveis de ambiente adicionais em {{path}}.',
// ── Builtin Providers ──
'builtin.title': 'Provedores integrados',
'builtin.description': 'Configure as chaves de API diretamente — sem ferramentas CLI necessárias.',
'builtin.addProvider': 'Adicionar provedor',
'builtin.empty': 'Nenhum provedor integrado configurado ainda.',
'builtin.displayName': 'Nome de exibição',
'builtin.displayNamePlaceholder': 'ex. Minha chave Anthropic',
'builtin.provider': 'Provedor',
'builtin.region': 'Região',
'builtin.regionChina': 'China',
'builtin.regionGlobal': 'Global',
'builtin.apiKey': 'API Key',
'builtin.model': 'Modelo',
'builtin.searchModels': 'Pesquisar modelos disponíveis',
'builtin.filterModels': 'Filtrar modelos...',
'builtin.noModels': 'Nenhum modelo encontrado',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (obrigatório)',
'builtin.apiFormat': 'Formato de API',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Pronto',
'builtin.add': 'Adicionar',
'builtin.searchError': 'Base URL é necessária para pesquisar modelos',
'builtin.custom': 'Personalizado',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'via API Key de {{name}}',
'builtin.errorProviderNotFound': 'Provedor integrado não encontrado. Por favor, verifique suas configurações.',
'builtin.errorApiKeyEmpty': 'A API key está vazia. Por favor, adicione sua API key nas configurações.',
'builtin.parallelAgents': 'Sub-agentes paralelos: {{count}}x (clique para alternar)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Selecione um modelo para geração de design. Quando definido, tarefas de design são automaticamente delegadas a um agente especializado usando este modelo.',
'builtin.teamDesignModel': 'Modelo de design',
'builtin.teamSelectModel': 'Nenhum (agente único)',
// ── Figma Import ──
'figma.title': 'Importar do Figma',
'figma.dropFile': 'Solte um arquivo .fig aqui',

View file

@ -362,6 +362,8 @@ const ru: TranslationKeys = {
'agents.port': 'Порт',
'agents.mcpRestart':
'Интеграции MCP вступят в силу после перезапуска терминала.',
'agents.mcpReinstallHint':
'После обновления OpenPencil переустановите интеграции MCP для обеспечения совместимости.',
'agents.modelCount': '{{count}} модель(ей)',
'agents.connectionFailed': 'Ошибка подключения',
'agents.serverError': 'Ошибка сервера {{status}}',
@ -404,6 +406,40 @@ const ru: TranslationKeys = {
'settings.systemDesktopOnly': 'Системные настройки доступны в настольном приложении.',
'settings.envHint': 'Вы можете задать дополнительные переменные окружения в {{path}}.',
// ── Builtin Providers ──
'builtin.title': 'Встроенные провайдеры',
'builtin.description': 'Настройте API-ключи напрямую — без CLI-инструментов.',
'builtin.addProvider': 'Добавить провайдера',
'builtin.empty': 'Встроенные провайдеры ещё не настроены.',
'builtin.displayName': 'Отображаемое имя',
'builtin.displayNamePlaceholder': 'напр. Мой ключ Anthropic',
'builtin.provider': 'Провайдер',
'builtin.region': 'Регион',
'builtin.regionChina': 'Китай',
'builtin.regionGlobal': 'Глобальный',
'builtin.apiKey': 'API Key',
'builtin.model': 'Модель',
'builtin.searchModels': 'Поиск доступных моделей',
'builtin.filterModels': 'Фильтр моделей...',
'builtin.noModels': 'Модели не найдены',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (обязательно)',
'builtin.apiFormat': 'Формат API',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Готово',
'builtin.add': 'Добавить',
'builtin.searchError': 'Для поиска моделей требуется Base URL',
'builtin.custom': 'Пользовательский',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'через API Key {{name}}',
'builtin.errorProviderNotFound': 'Встроенный провайдер не найден. Пожалуйста, проверьте настройки.',
'builtin.errorApiKeyEmpty': 'API key пуст. Пожалуйста, добавьте API key в настройках.',
'builtin.parallelAgents': 'Параллельные суб-агенты: {{count}}x (нажмите для переключения)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Выберите модель для генерации дизайна. При установке задачи дизайна автоматически делегируются специализированному агенту, использующему эту модель.',
'builtin.teamDesignModel': 'Модель дизайна',
'builtin.teamSelectModel': 'Нет (один агент)',
// ── Figma Import ──
'figma.title': 'Импорт из Figma',
'figma.dropFile': 'Перетащите файл .fig сюда',

View file

@ -360,6 +360,8 @@ const th: TranslationKeys = {
'agents.port': 'พอร์ต',
'agents.mcpRestart':
'การผสานรวม MCP จะมีผลหลังจากรีสตาร์ทเทอร์มินัล',
'agents.mcpReinstallHint':
'หลังจากอัปเกรด OpenPencil กรุณาติดตั้ง MCP Integration ใหม่เพื่อให้แน่ใจว่าเข้ากันได้',
'agents.modelCount': '{{count}} โมเดล',
'agents.connectionFailed': 'การเชื่อมต่อล้มเหลว',
'agents.serverError': 'ข้อผิดพลาดของเซิร์ฟเวอร์ {{status}}',
@ -402,6 +404,40 @@ const th: TranslationKeys = {
'settings.systemDesktopOnly': 'การตั้งค่าระบบใช้ได้ในแอปเดสก์ท็อป',
'settings.envHint': 'คุณสามารถตั้งค่าตัวแปรสภาพแวดล้อมเพิ่มเติมได้ใน {{path}}',
// ── Builtin Providers ──
'builtin.title': 'ผู้ให้บริการในตัว',
'builtin.description': 'กำหนดค่า API Key โดยตรง — ไม่ต้องใช้เครื่องมือ CLI',
'builtin.addProvider': 'เพิ่มผู้ให้บริการ',
'builtin.empty': 'ยังไม่มีผู้ให้บริการในตัวที่กำหนดค่าไว้',
'builtin.displayName': 'ชื่อที่แสดง',
'builtin.displayNamePlaceholder': 'เช่น Anthropic Key ของฉัน',
'builtin.provider': 'ผู้ให้บริการ',
'builtin.region': 'ภูมิภาค',
'builtin.regionChina': 'จีน',
'builtin.regionGlobal': 'ทั่วโลก',
'builtin.apiKey': 'API Key',
'builtin.model': 'โมเดล',
'builtin.searchModels': 'ค้นหาโมเดลที่มีอยู่',
'builtin.filterModels': 'กรองโมเดล...',
'builtin.noModels': 'ไม่พบโมเดล',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (จำเป็น)',
'builtin.apiFormat': 'รูปแบบ API',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'พร้อม',
'builtin.add': 'เพิ่ม',
'builtin.searchError': 'ต้องระบุ Base URL เพื่อค้นหาโมเดล',
'builtin.custom': 'กำหนดเอง',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'ผ่าน API Key ของ {{name}}',
'builtin.errorProviderNotFound': 'ไม่พบผู้ให้บริการในตัว กรุณาตรวจสอบการตั้งค่าของคุณ',
'builtin.errorApiKeyEmpty': 'API Key ว่างเปล่า กรุณาเพิ่ม API Key ในการตั้งค่า',
'builtin.parallelAgents': 'ตัวแทนย่อยแบบขนาน: {{count}}x (คลิกเพื่อสลับ)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'เลือกโมเดลสำหรับสร้างงานออกแบบ เมื่อตั้งค่าแล้ว งานออกแบบจะถูกมอบหมายให้เอเจนต์ผู้เชี่ยวชาญที่ใช้โมเดลนี้โดยอัตโนมัติ',
'builtin.teamDesignModel': 'โมเดลออกแบบ',
'builtin.teamSelectModel': 'ไม่มี (เอเจนต์เดี่ยว)',
// ── Figma Import ──
'figma.title': 'นำเข้าจาก Figma',
'figma.dropFile': 'วางไฟล์ .fig ที่นี่',

View file

@ -360,6 +360,8 @@ const tr: TranslationKeys = {
'agents.port': 'Port',
'agents.mcpRestart':
'MCP entegrasyonları terminal yeniden başlatıldıktan sonra etkin olacaktır.',
'agents.mcpReinstallHint':
'OpenPencil\'i yükselttikten sonra uyumluluğu sağlamak için MCP entegrasyonlarını yeniden yükleyin.',
'agents.modelCount': '{{count}} model',
'agents.connectionFailed': 'Bağlantı başarısız',
'agents.serverError': 'Sunucu hatası {{status}}',
@ -402,6 +404,40 @@ const tr: TranslationKeys = {
'settings.systemDesktopOnly': 'Sistem ayarları masaüstü uygulamasında kullanılabilir.',
'settings.envHint': '{{path}} dosyasında ek ortam değişkenleri ayarlayabilirsiniz.',
// ── Builtin Providers ──
'builtin.title': 'Yerleşik Sağlayıcılar',
'builtin.description': 'API anahtarlarını doğrudan yapılandırın — CLI araçlarına gerek yok.',
'builtin.addProvider': 'Sağlayıcı Ekle',
'builtin.empty': 'Henüz yerleşik sağlayıcı yapılandırılmadı.',
'builtin.displayName': 'Görünen Ad',
'builtin.displayNamePlaceholder': 'örn. Anthropic Anahtarım',
'builtin.provider': 'Sağlayıcı',
'builtin.region': 'Bölge',
'builtin.regionChina': 'Çin',
'builtin.regionGlobal': 'Küresel',
'builtin.apiKey': 'API Key',
'builtin.model': 'Model',
'builtin.searchModels': 'Mevcut modelleri ara',
'builtin.filterModels': 'Modelleri filtrele...',
'builtin.noModels': 'Model bulunamadı',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (zorunlu)',
'builtin.apiFormat': 'API Formatı',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Hazır',
'builtin.add': 'Ekle',
'builtin.searchError': 'Modelleri aramak için Base URL gereklidir',
'builtin.custom': 'Özel',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': '{{name}} API Key ile',
'builtin.errorProviderNotFound': 'Yerleşik sağlayıcı bulunamadı. Lütfen ayarlarınızı kontrol edin.',
'builtin.errorApiKeyEmpty': 'API Key boş. Lütfen ayarlardan API Key ekleyin.',
'builtin.parallelAgents': 'Paralel alt ajanlar: {{count}}x (döngü için tıklayın)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Tasarım oluşturma için bir model seçin. Ayarlandığında, tasarım görevleri otomatik olarak bu modeli kullanan uzman bir ajana devredilir.',
'builtin.teamDesignModel': 'Tasarım Modeli',
'builtin.teamSelectModel': 'Yok (tek ajan)',
// ── Figma Import ──
'figma.title': 'Figma\'dan İçe Aktar',
'figma.dropFile': 'Bir .fig dosyasını buraya bırakın',

View file

@ -360,6 +360,8 @@ const vi: TranslationKeys = {
'agents.port': 'Cổng',
'agents.mcpRestart':
'Các tích hợp MCP sẽ có hiệu lực sau khi khởi động lại terminal.',
'agents.mcpReinstallHint':
'Sau khi nâng cấp OpenPencil, vui lòng cài đặt lại tích hợp MCP để đảm bảo tương thích.',
'agents.modelCount': '{{count}} mô hình',
'agents.connectionFailed': 'Kết nối thất bại',
'agents.serverError': 'Lỗi máy chủ {{status}}',
@ -402,6 +404,40 @@ const vi: TranslationKeys = {
'settings.systemDesktopOnly': 'Cài đặt hệ thống khả dụng trong ứng dụng máy tính.',
'settings.envHint': 'Bạn có thể đặt thêm biến môi trường trong {{path}}.',
// ── Builtin Providers ──
'builtin.title': 'Nhà cung cấp tích hợp',
'builtin.description': 'Cấu hình API Key trực tiếp — không cần công cụ CLI.',
'builtin.addProvider': 'Thêm nhà cung cấp',
'builtin.empty': 'Chưa có nhà cung cấp tích hợp nào được cấu hình.',
'builtin.displayName': 'Tên hiển thị',
'builtin.displayNamePlaceholder': 'vd. Anthropic Key của tôi',
'builtin.provider': 'Nhà cung cấp',
'builtin.region': 'Khu vực',
'builtin.regionChina': 'Trung Quốc',
'builtin.regionGlobal': 'Toàn cầu',
'builtin.apiKey': 'API Key',
'builtin.model': 'Mô hình',
'builtin.searchModels': 'Tìm kiếm mô hình có sẵn',
'builtin.filterModels': 'Lọc mô hình...',
'builtin.noModels': 'Không tìm thấy mô hình',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL (bắt buộc)',
'builtin.apiFormat': 'Định dạng API',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': 'Sẵn sàng',
'builtin.add': 'Thêm',
'builtin.searchError': 'Cần có Base URL để tìm kiếm mô hình',
'builtin.custom': 'Tùy chỉnh',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': 'qua API Key của {{name}}',
'builtin.errorProviderNotFound': 'Không tìm thấy nhà cung cấp tích hợp. Vui lòng kiểm tra cài đặt của bạn.',
'builtin.errorApiKeyEmpty': 'API Key đang trống. Vui lòng thêm API Key trong cài đặt.',
'builtin.parallelAgents': 'Tác nhân phụ song song: {{count}}x (nhấn để chuyển đổi)',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': 'Chọn mô hình để tạo thiết kế. Khi được đặt, các tác vụ thiết kế sẽ tự động được giao cho agent chuyên dụng sử dụng mô hình này.',
'builtin.teamDesignModel': 'Mô hình thiết kế',
'builtin.teamSelectModel': 'Không (agent đơn)',
// ── Figma Import ──
'figma.title': 'Nhập từ Figma',
'figma.dropFile': 'Kéo thả tệp .fig vào đây',

View file

@ -352,6 +352,7 @@ const zhTW: TranslationKeys = {
'agents.transport': '傳輸方式',
'agents.port': '連接埠',
'agents.mcpRestart': 'MCP 整合將在重新啟動終端機後生效。',
'agents.mcpReinstallHint': '升級 OpenPencil 版本後,請重新安裝 MCP 整合以確保相容性。',
'agents.modelCount': '{{count}} 個模型',
'agents.connectionFailed': '連線失敗',
'agents.serverError': '伺服器錯誤 {{status}}',
@ -394,6 +395,40 @@ const zhTW: TranslationKeys = {
'settings.systemDesktopOnly': '系統設定僅在桌面應用程式中可用。',
'settings.envHint': '你可以在 {{path}} 中設定額外的環境變數。',
// ── Builtin Providers ──
'builtin.title': '內建服務商',
'builtin.description': '直接設定 API 金鑰 — 無需 CLI 工具。',
'builtin.addProvider': '新增服務商',
'builtin.empty': '尚未設定內建服務商。',
'builtin.displayName': '顯示名稱',
'builtin.displayNamePlaceholder': '例如 我的 Anthropic 金鑰',
'builtin.provider': '服務商',
'builtin.region': '區域',
'builtin.regionChina': '中國',
'builtin.regionGlobal': '全球',
'builtin.apiKey': 'API Key',
'builtin.model': '模型',
'builtin.searchModels': '搜尋可用模型',
'builtin.filterModels': '篩選模型...',
'builtin.noModels': '找不到模型',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL必填',
'builtin.apiFormat': 'API 格式',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': '就緒',
'builtin.add': '新增',
'builtin.searchError': '搜尋模型需要提供 Base URL',
'builtin.custom': '自訂',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': '透過 {{name}} API Key',
'builtin.errorProviderNotFound': '找不到內建服務商,請檢查您的設定。',
'builtin.errorApiKeyEmpty': 'API 金鑰為空,請在設定中新增您的 API 金鑰。',
'builtin.parallelAgents': '並行子代理:{{count}}x點擊切換',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': '選擇用於設計生成的模型。設定後,設計任務將自動委派給使用此模型的專業 Agent。',
'builtin.teamDesignModel': '設計模型',
'builtin.teamSelectModel': '無(單 Agent',
// ── Figma Import ──
'figma.title': '從 Figma 匯入',
'figma.dropFile': '將 .fig 檔案拖放至此處',

View file

@ -352,6 +352,7 @@ const zh: TranslationKeys = {
'agents.transport': '传输方式',
'agents.port': '端口',
'agents.mcpRestart': 'MCP 集成将在重启终端后生效。',
'agents.mcpReinstallHint': '升级 OpenPencil 版本后,请重新安装 MCP 集成以确保兼容性。',
'agents.modelCount': '{{count}} 个模型',
'agents.connectionFailed': '连接失败',
'agents.serverError': '服务器错误 {{status}}',
@ -394,6 +395,40 @@ const zh: TranslationKeys = {
'settings.systemDesktopOnly': '系统设置仅在桌面应用中可用。',
'settings.envHint': '你可以在 {{path}} 中设置额外的环境变量。',
// ── Builtin Providers ──
'builtin.title': '内置服务商',
'builtin.description': '直接配置 API 密钥 — 无需 CLI 工具。',
'builtin.addProvider': '添加服务商',
'builtin.empty': '尚未配置内置服务商。',
'builtin.displayName': '显示名称',
'builtin.displayNamePlaceholder': '例如 我的 Anthropic 密钥',
'builtin.provider': '服务商',
'builtin.region': '区域',
'builtin.regionChina': '中国',
'builtin.regionGlobal': '全球',
'builtin.apiKey': 'API Key',
'builtin.model': '模型',
'builtin.searchModels': '搜索可用模型',
'builtin.filterModels': '筛选模型...',
'builtin.noModels': '未找到模型',
'builtin.baseUrl': 'Base URL',
'builtin.baseUrlRequired': 'Base URL必填',
'builtin.apiFormat': 'API 格式',
'builtin.openaiCompat': 'OpenAI Compatible',
'builtin.ready': '就绪',
'builtin.add': '添加',
'builtin.searchError': '搜索模型需要提供 Base URL',
'builtin.custom': '自定义',
'builtin.apiKeyBadge': 'API Key',
'builtin.viaApiKey': '通过 {{name}} API Key',
'builtin.errorProviderNotFound': '未找到内置服务商,请检查您的设置。',
'builtin.errorApiKeyEmpty': 'API 密钥为空,请在设置中添加您的 API 密钥。',
'builtin.parallelAgents': '并行子代理:{{count}}x点击切换',
'builtin.baseUrlPlaceholder': 'https://api.example.com/v1',
'builtin.teamDescription': '选择用于设计生成的模型。设置后,设计任务将自动委派给使用此模型的专业 Agent。',
'builtin.teamDesignModel': '设计模型',
'builtin.teamSelectModel': '无(单 Agent',
// ── Figma Import ──
'figma.title': '从 Figma 导入',
'figma.dropFile': '将 .fig 文件拖放到此处',

View file

@ -1,12 +1,18 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { writeFile, unlink, mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { sanitizeObject } from '../utils/sanitize'
import { openDocument, invalidateCache } from '../document-manager'
import {
openDocument,
invalidateCache,
probeLiveSyncUrl,
buildLiveSyncMessage,
} from '../document-manager'
import { handleBatchDesign } from '../tools/batch-design'
const TMP_DIR = join(tmpdir(), 'openpencil-security-tests')
const originalFetch = globalThis.fetch
beforeEach(async () => {
await mkdir(TMP_DIR, { recursive: true })
@ -22,6 +28,8 @@ afterEach(async () => {
await unlink(fp)
} catch {}
}
vi.restoreAllMocks()
globalThis.fetch = originalFetch
})
// ---------- sanitizeObject ----------
@ -123,6 +131,33 @@ describe('openDocument', () => {
})
})
describe('live sync diagnostics', () => {
it('treats 404 document endpoint as reachable but missing live document', async () => {
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/api/mcp/document')) {
return new Response('{}', { status: 404 })
}
return new Response('{}', { status: 200 })
}) as typeof fetch
await expect(probeLiveSyncUrl('http://127.0.0.1:3000')).resolves.toBe('no-document')
})
it('reports unreachable when all live sync probes fail', async () => {
globalThis.fetch = vi.fn(async () => {
throw new Error('connect failed')
}) as typeof fetch
await expect(probeLiveSyncUrl('http://127.0.0.1:3000')).resolves.toBe('unreachable')
})
it('builds a clear unreachable message', () => {
expect(buildLiveSyncMessage('unreachable', 3000)).toContain('port 3000')
expect(buildLiveSyncMessage('unreachable', 3000)).toContain('unreachable')
})
})
// ---------- batch-design (parseJsonArg sanitization) ----------
describe('handleBatchDesign', () => {

View file

@ -1,4 +1,4 @@
import { readFile, writeFile, access } from 'node:fs/promises'
import { readFile, writeFile, access, unlink } from 'node:fs/promises'
import { constants } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
@ -17,7 +17,104 @@ export function resolveDocPath(filePath?: string): string {
return resolve(filePath)
}
// ---------------------------------------------------------------------------
// Sync URL cache — avoids repeated port file reads + health checks
// ---------------------------------------------------------------------------
let _cachedSyncUrl: string | null = null
let _cachedSyncUrlTime = 0
const SYNC_URL_TTL = 30_000 // 30 seconds
/** Pre-set the sync URL (e.g. from CLI connection discovery). */
export function setSyncUrl(url: string): void {
_cachedSyncUrl = url
_cachedSyncUrlTime = Date.now()
}
/** Clear the cached sync URL (e.g. after connection failure). */
export function clearSyncUrl(): void {
_cachedSyncUrl = null
_cachedSyncUrlTime = 0
}
const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR_NAME, PORT_FILE_NAME)
const SYNC_BASE_URLS = ['http://127.0.0.1', 'http://localhost']
async function getReachableSyncUrl(port: number): Promise<string | null> {
for (const baseUrl of SYNC_BASE_URLS) {
const url = `${baseUrl}:${port}/api/mcp/server`
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(500),
})
if (res.ok) return `${baseUrl}:${port}`
} catch {
// Server may still be starting, or the port file may be stale.
}
if (attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
}
return null
}
type LiveSyncAvailability = 'connected' | 'no-document' | 'unreachable' | 'missing-port-file'
export interface LiveSyncState {
status: LiveSyncAvailability
url: string | null
port: number | null
message: string
}
async function probeLiveSyncUrl(baseUrl: string): Promise<LiveSyncAvailability> {
try {
const docRes = await fetch(`${baseUrl}/api/mcp/document`, {
signal: AbortSignal.timeout(500),
})
if (docRes.ok) return 'connected'
if (docRes.status === 404) return 'no-document'
} catch {
// Ignore and try lighter health probes below.
}
try {
const selectionRes = await fetch(`${baseUrl}/api/mcp/selection`, {
signal: AbortSignal.timeout(500),
})
if (selectionRes.ok) return 'no-document'
} catch {
// Ignore and try generic server status.
}
try {
const serverRes = await fetch(`${baseUrl}/api/mcp/server`, {
signal: AbortSignal.timeout(500),
})
if (serverRes.ok) return 'no-document'
} catch {
// Ignore.
}
return 'unreachable'
}
function buildLiveSyncMessage(status: LiveSyncAvailability, port?: number | null): string {
const portHint = port ? ` (port ${port})` : ''
switch (status) {
case 'connected':
return `Connected to OpenPencil live canvas${portHint}.`
case 'no-document':
return `OpenPencil is running${portHint}, but no live document is loaded in the editor yet. Open the editor page and wait for sync.`
case 'unreachable':
return `Found an OpenPencil port file${portHint}, but the live sync server is unreachable. Restart OpenPencil and try again.`
case 'missing-port-file':
default:
return 'No running OpenPencil instance found. Start the Electron app or dev server first.'
}
}
// ---------------------------------------------------------------------------
// Sync URL discovery
@ -34,25 +131,100 @@ function isPidAlive(pid: number): boolean {
/** Read the port file and return the Nitro sync base URL, or null if unavailable. */
export async function getSyncUrl(): Promise<string | null> {
// Use cached URL if still fresh
if (_cachedSyncUrl && Date.now() - _cachedSyncUrlTime < SYNC_URL_TTL) {
return _cachedSyncUrl
}
const state = await getLiveSyncState()
if ((state.status === 'connected' || state.status === 'no-document') && state.url) {
_cachedSyncUrl = state.url
_cachedSyncUrlTime = Date.now()
return state.url
}
return null
}
/** Inspect the current live canvas sync state with a user-facing diagnosis. */
export async function getLiveSyncState(): Promise<LiveSyncState> {
try {
const raw = await readFile(PORT_FILE_PATH, 'utf-8')
const { port, pid } = JSON.parse(raw) as { port: number; pid: number }
if (!isPidAlive(pid)) return null
return `http://127.0.0.1:${port}`
const url = await getReachableSyncUrl(port)
if (url) {
const status = await probeLiveSyncUrl(url)
if (status === 'unreachable') {
return {
status,
url: null,
port,
message: buildLiveSyncMessage(status, port),
}
}
return {
status,
url,
port,
message: buildLiveSyncMessage(status, port),
}
}
if (!isPidAlive(pid)) {
try {
await unlink(PORT_FILE_PATH)
} catch {
// Ignore cleanup failures for stale port files.
}
return {
status: 'missing-port-file',
url: null,
port: null,
message: buildLiveSyncMessage('missing-port-file'),
}
}
return {
status: 'unreachable',
url: null,
port,
message: buildLiveSyncMessage('unreachable', port),
}
} catch {
return null
return {
status: 'missing-port-file',
url: null,
port: null,
message: buildLiveSyncMessage('missing-port-file'),
}
}
}
/** Fetch the current document from the live Electron canvas. */
async function fetchLiveDocument(): Promise<PenDocument> {
const syncUrl = await getSyncUrl()
if (!syncUrl) {
throw new Error(
'No running OpenPencil instance found. Start the Electron app or dev server first.',
)
// Fast path: use cached sync URL
const cachedUrl = _cachedSyncUrl && Date.now() - _cachedSyncUrlTime < SYNC_URL_TTL
? _cachedSyncUrl : null
if (cachedUrl) {
try {
const res = await fetch(`${cachedUrl}/api/mcp/document`)
if (res.ok) {
const data = (await res.json()) as { document: PenDocument }
return data.document
}
} catch {
// Cache stale — fall through to full discovery
clearSyncUrl()
}
}
const res = await fetch(`${syncUrl}/api/mcp/document`)
const sync = await getLiveSyncState()
if (!sync.url || sync.status !== 'connected') {
throw new Error(sync.message)
}
_cachedSyncUrl = sync.url
_cachedSyncUrlTime = Date.now()
const res = await fetch(`${sync.url}/api/mcp/document`)
if (!res.ok) {
const body = await res.json().catch(() => ({} as Record<string, unknown>))
throw new Error(
@ -65,7 +237,10 @@ async function fetchLiveDocument(): Promise<PenDocument> {
/** Push document to the live Electron canvas. Fails silently if unavailable. */
async function pushLiveDocument(doc: PenDocument): Promise<void> {
const syncUrl = await getSyncUrl()
// Fast path: use cached sync URL
const cachedUrl = _cachedSyncUrl && Date.now() - _cachedSyncUrlTime < SYNC_URL_TTL
? _cachedSyncUrl : null
const syncUrl = cachedUrl ?? await getSyncUrl()
if (!syncUrl) return
try {
await fetch(`${syncUrl}/api/mcp/document`, {
@ -75,6 +250,7 @@ async function pushLiveDocument(doc: PenDocument): Promise<void> {
})
} catch {
// Network error — Electron might have quit between check and request
clearSyncUrl()
}
}
@ -174,19 +350,39 @@ export async function fileExists(filePath: string): Promise<boolean> {
/** Fetch the current selection from the live Electron canvas. */
export async function fetchLiveSelection(): Promise<{ selectedIds: string[]; activePageId: string | null }> {
const syncUrl = await getSyncUrl()
if (!syncUrl) {
// Fast path: use cached sync URL
const cachedUrl = _cachedSyncUrl && Date.now() - _cachedSyncUrlTime < SYNC_URL_TTL
? _cachedSyncUrl : null
if (cachedUrl) {
try {
const res = await fetch(`${cachedUrl}/api/mcp/selection`)
if (res.ok) return (await res.json()) as { selectedIds: string[]; activePageId: string | null }
} catch {
clearSyncUrl()
}
}
const sync = await getLiveSyncState()
if (!sync.url) {
throw new Error(sync.message)
}
if (sync.status === 'no-document') {
return { selectedIds: [], activePageId: null }
}
_cachedSyncUrl = sync.url
_cachedSyncUrlTime = Date.now()
try {
const res = await fetch(`${syncUrl}/api/mcp/selection`)
const res = await fetch(`${sync.url}/api/mcp/selection`)
if (!res.ok) return { selectedIds: [], activePageId: null }
return (await res.json()) as { selectedIds: string[]; activePageId: string | null }
} catch {
return { selectedIds: [], activePageId: null }
throw new Error(buildLiveSyncMessage('unreachable', sync.port))
}
}
export { probeLiveSyncUrl, buildLiveSyncMessage }
/** Invalidate cache for a file. */
export function invalidateCache(filePath: string): void {
cache.delete(filePath)

View file

@ -40,6 +40,7 @@ import { handleDesignSkeleton } from './tools/design-skeleton'
import { handleDesignContent } from './tools/design-content'
import { handleDesignRefine } from './tools/design-refine'
import { handleGetSelection } from './tools/get-selection'
import { handleExportNodes } from './tools/export-nodes'
import { LAYERED_DESIGN_TOOLS } from './tools/layered-design-defs'
import { MCP_DEFAULT_PORT } from '@/constants/app'
@ -561,6 +562,30 @@ const TOOL_DEFINITIONS = [
required: ['operations'],
},
},
{
name: 'export_nodes',
description:
'Export raw PenNode data with design variables and themes. Pure data export — no AI, no analysis. Use with get_design_prompt(section="codegen-*") to let your LLM generate code.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Path to .op file. If omitted, uses the currently opened document.',
},
nodeIds: {
type: 'array',
items: { type: 'string' },
description: 'Specific node IDs to export. If omitted, exports all nodes on the target page.',
},
pageId: {
type: 'string',
description: 'Target page ID. If omitted, uses the first/active page.',
},
},
required: [],
},
},
...LAYERED_DESIGN_TOOLS,
]
@ -635,6 +660,8 @@ async function handleToolCall(name: string, args: Record<string, unknown> | unde
)
case 'batch_design':
return JSON.stringify(await handleBatchDesign(a), null, 2)
case 'export_nodes':
return JSON.stringify(await handleExportNodes(a), null, 2)
case 'design_skeleton':
return JSON.stringify(await handleDesignSkeleton(a), null, 2)
case 'design_content':

View file

@ -231,6 +231,17 @@ type PromptSection =
| 'overflow'
| 'cjk'
| 'variables'
| 'codegen-planning'
| 'codegen-chunk'
| 'codegen-assembly'
| 'codegen-react'
| 'codegen-vue'
| 'codegen-svelte'
| 'codegen-html'
| 'codegen-flutter'
| 'codegen-swiftui'
| 'codegen-compose'
| 'codegen-react-native'
// Dynamic section map — skills from registry, local sections for planning/variables/design-md
const SECTION_MAP: Record<PromptSection, () => string> = {
@ -249,6 +260,17 @@ const SECTION_MAP: Record<PromptSection, () => string> = {
overflow: () => getSkillContent('overflow'),
cjk: () => getSkillContent('cjk'),
variables: () => VARIABLE_RULES,
'codegen-planning': () => getSkillContent('codegen-planning'),
'codegen-chunk': () => getSkillContent('codegen-chunk'),
'codegen-assembly': () => getSkillContent('codegen-assembly'),
'codegen-react': () => getSkillContent('codegen-react'),
'codegen-vue': () => getSkillContent('codegen-vue'),
'codegen-svelte': () => getSkillContent('codegen-svelte'),
'codegen-html': () => getSkillContent('codegen-html'),
'codegen-flutter': () => getSkillContent('codegen-flutter'),
'codegen-swiftui': () => getSkillContent('codegen-swiftui'),
'codegen-compose': () => getSkillContent('codegen-compose'),
'codegen-react-native': () => getSkillContent('codegen-react-native'),
}
// Design.md content injected via setDesignMdForPrompt()

View file

@ -0,0 +1,40 @@
import type { PenNode } from '@/types/pen'
import { openDocument, resolveDocPath } from '../document-manager'
import { findNodeInTree, getDocChildren } from '../utils/node-operations'
export interface ExportNodesParams {
filePath?: string
nodeIds?: string[]
pageId?: string
}
interface ExportNodesResult {
nodes: PenNode[]
variables: Record<string, unknown>
themes: unknown[]
}
export async function handleExportNodes(
params: ExportNodesParams,
): Promise<ExportNodesResult> {
const filePath = resolveDocPath(params.filePath)
const doc = await openDocument(filePath)
const pageChildren = getDocChildren(doc, params.pageId)
let nodes: PenNode[]
if (params.nodeIds && params.nodeIds.length > 0) {
nodes = params.nodeIds
.map((id) => findNodeInTree(pageChildren, id))
.filter((n): n is PenNode => n !== undefined)
} else {
nodes = pageChildren
}
return {
nodes,
variables: doc.variables ?? {},
themes: (doc as { themes?: unknown[] }).themes ?? [],
}
}

View file

@ -3,7 +3,6 @@ import {
createEmptyDocument,
saveDocument,
fileExists,
getSyncUrl,
resolveDocPath,
LIVE_CANVAS_PATH,
} from '../document-manager'
@ -37,20 +36,10 @@ export async function handleOpenDocument(
let doc: PenDocument
if (!params.filePath || params.filePath === LIVE_CANVAS_PATH) {
// Live canvas mode: connect to the running Electron/dev server
const syncUrl = await getSyncUrl()
if (syncUrl) {
filePath = LIVE_CANVAS_PATH
doc = await openDocument(LIVE_CANVAS_PATH)
} else if (params.filePath === LIVE_CANVAS_PATH) {
throw new Error(
'No running OpenPencil instance found. Start the Electron app or dev server first.',
)
} else {
throw new Error(
'filePath is required when no OpenPencil instance is running. Provide a path to an existing .op file or a new file to create.',
)
}
// Live canvas mode: connect to the running Electron/dev server.
// openDocument() now returns a more precise diagnostic when sync is unavailable.
filePath = LIVE_CANVAS_PATH
doc = await openDocument(LIVE_CANVAS_PATH)
} else {
filePath = resolveDocPath(params.filePath)
const exists = await fileExists(filePath)

View file

@ -0,0 +1,102 @@
// apps/web/src/services/ai/__tests__/code-generation-pipeline.test.ts
import { describe, it, expect } from 'vitest'
import { hydratePlan, computeExecutionOrder, parseChunkResponse } from '../code-generation-pipeline'
import type { CodePlanFromAI, PlannedChunk } from '@zseven-w/pen-codegen'
import type { PenNode } from '@zseven-w/pen-types'
describe('hydratePlan', () => {
it('maps nodeIds to actual PenNode objects', () => {
const nodes: PenNode[] = [
{ id: 'n1', type: 'frame', name: 'Header', x: 0, y: 0, width: 100, height: 50 } as PenNode,
{ id: 'n2', type: 'text', name: 'Title', x: 0, y: 0, width: 80, height: 20 } as PenNode,
]
const plan: CodePlanFromAI = {
chunks: [
{ id: 'c1', name: 'header', nodeIds: ['n1', 'n2'], role: 'navbar', suggestedComponentName: 'Header', dependencies: [] },
],
sharedStyles: [],
rootLayout: { direction: 'vertical', gap: 0, responsive: true },
}
const result = hydratePlan(plan, nodes)
expect(result.chunks[0].nodes).toHaveLength(2)
expect(result.chunks[0].nodes[0].id).toBe('n1')
})
it('strips chunks with no valid nodeIds', () => {
const nodes: PenNode[] = [
{ id: 'n1', type: 'frame', name: 'Header', x: 0, y: 0, width: 100, height: 50 } as PenNode,
]
const plan: CodePlanFromAI = {
chunks: [
{ id: 'c1', name: 'header', nodeIds: ['n1'], role: 'navbar', suggestedComponentName: 'Header', dependencies: [] },
{ id: 'c2', name: 'ghost', nodeIds: ['nonexistent'], role: 'card', suggestedComponentName: 'Ghost', dependencies: [] },
],
sharedStyles: [],
rootLayout: { direction: 'vertical', gap: 0, responsive: true },
}
const result = hydratePlan(plan, nodes)
expect(result.chunks).toHaveLength(1)
expect(result.chunks[0].id).toBe('c1')
})
})
describe('computeExecutionOrder', () => {
it('assigns order 0 to chunks with no dependencies', () => {
const chunks: PlannedChunk[] = [
{ id: 'c1', name: 'a', nodeIds: [], role: '', suggestedComponentName: 'A', dependencies: [] },
{ id: 'c2', name: 'b', nodeIds: [], role: '', suggestedComponentName: 'B', dependencies: [] },
]
const orders = computeExecutionOrder(chunks)
expect(orders.get('c1')).toBe(0)
expect(orders.get('c2')).toBe(0)
})
it('assigns higher order to dependent chunks', () => {
const chunks: PlannedChunk[] = [
{ id: 'c1', name: 'a', nodeIds: [], role: '', suggestedComponentName: 'A', dependencies: [] },
{ id: 'c2', name: 'b', nodeIds: [], role: '', suggestedComponentName: 'B', dependencies: ['c1'] },
{ id: 'c3', name: 'c', nodeIds: [], role: '', suggestedComponentName: 'C', dependencies: ['c2'] },
]
const orders = computeExecutionOrder(chunks)
expect(orders.get('c1')).toBe(0)
expect(orders.get('c2')).toBe(1)
expect(orders.get('c3')).toBe(2)
})
})
describe('parseChunkResponse', () => {
it('splits code and contract from ---CONTRACT--- separator', () => {
const response = `export function NavBar() { return <nav /> }
---CONTRACT---
{"chunkId":"c1","componentName":"NavBar","exportedProps":[],"slots":[],"cssClasses":[],"cssVariables":[],"imports":[]}`
const result = parseChunkResponse(response, 'c1')
expect(result.code).toContain('NavBar')
expect(result.contract.componentName).toBe('NavBar')
expect(result.contract.chunkId).toBe('c1')
})
it('infers component name when no separator found', () => {
const response = 'export function NavBar() { return <nav /> }'
const result = parseChunkResponse(response, 'c1')
expect(result.code).toContain('NavBar')
expect(result.contract.componentName).toBe('NavBar')
})
it('extracts contract from markdown json block', () => {
const response = `\`\`\`tsx
export function HeroSection() { return <div /> }
\`\`\`
\`\`\`json
{"componentName":"HeroSection","exportedProps":[],"slots":[],"cssClasses":[],"cssVariables":[],"imports":[]}
\`\`\``
const result = parseChunkResponse(response, 'c2')
expect(result.code).toContain('HeroSection')
expect(result.contract.componentName).toBe('HeroSection')
expect(result.contract.chunkId).toBe('c2')
})
})

View file

@ -0,0 +1,492 @@
import type { AgentEvent, ToolResult, AuthLevel } from '@zseven-w/agent'
import type { PenNode } from '@/types/pen'
type ToolCallEvent = Extract<AgentEvent, { type: 'tool_call' }>
/** Auth levels that mutate the document and should be wrapped in an undo batch. */
const WRITE_LEVELS: Set<AuthLevel> = new Set(['create', 'modify', 'delete'])
/**
* Client-side tool executor.
*
* Receives `tool_call` events from the SSE stream, dispatches them against the
* live Zustand document store, wraps write operations in an undo batch, and
* POSTs the result back to the server to unblock the agent loop.
*/
export class AgentToolExecutor {
private sessionId: string
/** Track root-level insert to prevent duplicate designs */
private rootInsertId: string | null = null
constructor(sessionId: string) {
this.sessionId = sessionId
}
async execute(toolCall: ToolCallEvent): Promise<void> {
const { id, name, args, level } = toolCall
const isWrite = WRITE_LEVELS.has(level)
if (isWrite) {
const { useHistoryStore } = await import('@/stores/history-store')
const { useDocumentStore } = await import('@/stores/document-store')
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
}
let result: ToolResult
try {
result = await this.dispatch(name, args)
} catch (err) {
result = { success: false, error: (err instanceof Error ? err.message : JSON.stringify(err)) }
}
if (isWrite) {
const { useHistoryStore } = await import('@/stores/history-store')
const { useDocumentStore } = await import('@/stores/document-store')
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
}
// Post result back to server to unblock the agent loop.
// Retry once on failure — if the POST is lost, the agent hangs.
const payload = JSON.stringify({ sessionId: this.sessionId, toolCallId: id, result })
const postHeaders = { 'Content-Type': 'application/json' }
try {
const res = await fetch('/api/ai/agent?action=result', { method: 'POST', headers: postHeaders, body: payload })
if (!res.ok) throw new Error(`Status ${res.status}`)
} catch {
// Retry once
try {
await fetch('/api/ai/agent?action=result', { method: 'POST', headers: postHeaders, body: payload })
} catch (retryErr) {
console.error(`[AgentToolExecutor] Failed to post tool result ${id}:`, retryErr)
}
}
}
// ---------------------------------------------------------------------------
// Tool dispatch
// ---------------------------------------------------------------------------
private async dispatch(name: string, args: unknown): Promise<ToolResult> {
switch (name) {
case 'batch_get':
return this.handleBatchGet(args as { ids?: string[]; patterns?: string[] })
case 'snapshot_layout':
return this.handleSnapshotLayout(args as { pageId?: string })
case 'generate_design':
return this.handleGenerateDesign(args as { prompt: string; canvasWidth?: number })
case 'insert_node':
return this.handleInsertNode(
args as { parent: string | null; data: Record<string, unknown>; pageId?: string },
)
case 'update_node':
return this.handleUpdateNode(args as { id: string; data: Record<string, unknown> })
case 'delete_node':
return this.handleDeleteNode(args as { id: string })
case 'find_empty_space':
return this.handleFindEmptySpace(
args as { width: number; height: number; pageId?: string },
)
default:
return { success: false, error: `Unknown tool: ${name}` }
}
}
// ---------------------------------------------------------------------------
// generate_design — calls the SAME internal pipeline as the chat design flow
// ---------------------------------------------------------------------------
/**
* Generate a design using the EXISTING internal pipeline (orchestrator sub-agents
* insertStreamingNode). This is the same path that works with M2.5 and all models
* through the standard chat interface. The agent just provides the prompt.
*/
private async handleGenerateDesign(
args: { prompt?: string; description?: string; canvasWidth?: number },
): Promise<ToolResult> {
// Some models use 'description' instead of 'prompt'
const prompt = args.prompt || args.description
if (!prompt) return { success: false, error: 'Missing prompt or description' }
if (this.rootInsertId) {
return {
success: true,
data: { message: `Design already created. Use update_node to modify.` },
}
}
const { generateDesign } = await import('@/services/ai/design-generator')
const { useDocumentStore } = await import('@/stores/document-store')
const { useAgentSettingsStore } = await import('@/stores/agent-settings-store')
const { getCanvasSize } = await import('@/canvas/skia-engine-ref')
const docStore = useDocumentStore.getState()
const canvasSize = getCanvasSize()
// Find a provider for the internal pipeline:
// 1. First try connected CLI providers (Claude Code, Codex, etc.)
// 2. Fall back to the current builtin provider (API key)
const agentSettings = useAgentSettingsStore.getState()
const providers = agentSettings.providers ?? {}
let designModel = 'default'
let designProvider: string | undefined
for (const [key, cfg] of Object.entries(providers)) {
if (cfg.isConnected && cfg.models?.length) {
designProvider = key
designModel = cfg.models[0].value
break
}
}
// If no CLI provider connected, use builtin provider via the 'builtin' provider path.
// The chat endpoint reads builtin credentials from the request body (set by ai-service.ts).
if (!designProvider) {
const { useAIStore } = await import('@/stores/ai-store')
const currentModel = useAIStore.getState().model
if (currentModel.startsWith('builtin:')) {
const parts = currentModel.split(':')
const bpId = parts[1]
const bp = agentSettings.builtinProviders.find((p) => p.id === bpId)
if (bp?.apiKey) {
designProvider = 'builtin' as any
designModel = parts.slice(2).join(':')
}
}
}
// Mark as in-progress BEFORE calling generateDesign to prevent duplicate calls.
// If the first call fails midway, partial nodes are cleaned up below.
this.rootInsertId = 'generating'
// Snapshot current node IDs so we can clean up partial nodes on failure
const nodeIdsBefore = new Set(docStore.getFlatNodes().map(n => n.id))
// Match the CLI pipeline's generateDesign call exactly
const { useAIStore: aiStore } = await import('@/stores/ai-store')
const concurrency = aiStore.getState().concurrency
const doc = docStore.document
let designMd: any
try {
const { useDesignMdStore } = await import('@/stores/design-md-store')
designMd = useDesignMdStore?.getState()?.designMd
} catch { /* store may not exist */ }
let result: { nodes: unknown[] }
try {
result = await generateDesign(
{
prompt,
model: designModel,
provider: designProvider as any,
concurrency,
context: {
canvasSize,
documentSummary: `Document has ${docStore.getFlatNodes().length} nodes`,
variables: doc.variables,
themes: doc.themes,
designMd,
},
},
{
onApplyPartial: () => {},
onTextUpdate: () => {},
animated: true,
},
)
} catch (err) {
// Clean up partial nodes inserted before the failure
const currentNodes = docStore.getFlatNodes()
const newNodes = currentNodes.filter(n => !nodeIdsBefore.has(n.id))
for (const n of newNodes) {
try { docStore.removeNode(n.id) } catch { /* ignore */ }
}
this.rootInsertId = null // Allow retry
return { success: false, error: `Design generation failed: ${(err instanceof Error ? err.message : JSON.stringify(err))}` }
}
this.rootInsertId = 'generated'
// Auto-zoom
try {
const { zoomToFitContent } = await import('@/canvas/skia-engine-ref')
setTimeout(() => zoomToFitContent(), 300)
} catch { /* ignore */ }
return {
success: true,
data: {
nodeCount: result.nodes.length,
message: `Design generated with ${result.nodes.length} nodes via internal pipeline. Do NOT retry.`,
},
}
}
// ---------------------------------------------------------------------------
// Read tools
// ---------------------------------------------------------------------------
private async handleBatchGet(
args: { ids?: string[]; patterns?: string[] },
): Promise<ToolResult> {
const { useDocumentStore } = await import('@/stores/document-store')
const docStore = useDocumentStore.getState()
if (!args.ids?.length && !args.patterns?.length) {
const children = docStore.document.children ?? []
const nodes = children.map((n) => ({
id: n.id,
name: n.name,
type: n.type,
}))
return { success: true, data: nodes }
}
const results: Record<string, unknown>[] = []
const seen = new Set<string>()
if (args.ids?.length) {
for (const id of args.ids) {
if (seen.has(id)) continue
const node = docStore.getNodeById(id)
if (node) {
seen.add(id)
results.push({ ...node })
}
}
}
if (args.patterns?.length) {
const flat = docStore.getFlatNodes()
for (const pattern of args.patterns) {
const regex = new RegExp(pattern, 'i')
for (const node of flat) {
if (seen.has(node.id)) continue
if (regex.test(node.name ?? '') || regex.test(node.type)) {
seen.add(node.id)
results.push({ ...node })
}
}
}
}
return { success: true, data: results }
}
private async handleSnapshotLayout(
args: { pageId?: string },
): Promise<ToolResult> {
const { useDocumentStore, getActivePageChildren, getAllChildren } =
await import('@/stores/document-store')
const { useCanvasStore } = await import('@/stores/canvas-store')
const doc = useDocumentStore.getState().document
const pageId = args.pageId ?? useCanvasStore.getState().activePageId
const children = getActivePageChildren(doc, pageId)
const allChildren = getAllChildren(doc)
const { getNodeBounds } = await import('@/stores/document-tree-utils')
const buildLayout = (
nodes: typeof children,
maxDepth: number,
depth = 0,
): { id: string; name?: string; type: string; x: number; y: number; width: number; height: number; children?: unknown[] }[] =>
nodes.map((node) => {
const b = getNodeBounds(node, allChildren)
const entry: {
id: string
name?: string
type: string
x: number
y: number
width: number
height: number
children?: unknown[]
} = {
id: node.id,
name: node.name,
type: node.type,
x: b.x,
y: b.y,
width: b.w,
height: b.h,
}
if ('children' in node && node.children?.length && depth < maxDepth) {
entry.children = buildLayout(node.children, maxDepth, depth + 1)
}
return entry
})
return { success: true, data: buildLayout(children, 1) }
}
private async handleFindEmptySpace(
args: { width: number; height: number; pageId?: string },
): Promise<ToolResult> {
const { useDocumentStore, getActivePageChildren, getAllChildren } =
await import('@/stores/document-store')
const { useCanvasStore } = await import('@/stores/canvas-store')
const { getNodeBounds } = await import('@/stores/document-tree-utils')
const doc = useDocumentStore.getState().document
const pageId = args.pageId ?? useCanvasStore.getState().activePageId
const children = getActivePageChildren(doc, pageId)
const allChildren = getAllChildren(doc)
const padding = 50
if (children.length === 0) {
return { success: true, data: { x: 0, y: 0 } }
}
let minY = Infinity
let maxX = -Infinity
for (const node of children) {
const b = getNodeBounds(node, allChildren)
if (b.x + b.w > maxX) maxX = b.x + b.w
if (b.y < minY) minY = b.y
}
return { success: true, data: { x: maxX + padding, y: minY } }
}
// ---------------------------------------------------------------------------
// Write tools
// ---------------------------------------------------------------------------
/**
* Insert a node with full support for nested children.
* After insertion, runs the same post-processing as the MCP batch_design:
* role resolution, icon resolution, layout sanitization, unique IDs.
*/
/**
* Insert a node aligned with MCP batch_design behavior:
* 1. Parse stringified data
* 2. Sanitize invalid properties (borderstrokes, etc.)
* 3. Auto-replace empty root frame (same as batch_design line 146-161)
* 4. Post-process: role resolution, icon resolution, layout sanitization
* 5. Auto-zoom to show new design
*/
private async handleInsertNode(
args: { parent: string | null; data: Record<string, unknown>; pageId?: string },
): Promise<ToolResult> {
// Prevent duplicate root-level design inserts (common with weaker models)
if (args.parent === null && this.rootInsertId) {
return {
success: true,
data: {
id: this.rootInsertId,
message: `Design already created (id: ${this.rootInsertId}). Use update_node to modify it. Do NOT insert again.`,
},
}
}
const { nanoid } = await import('nanoid')
// Some models send data as a JSON string instead of an object — parse it
let nodeData = args.data
if (typeof nodeData === 'string') {
try { nodeData = JSON.parse(nodeData) } catch {
return { success: false, error: 'Invalid node data: could not parse JSON string' }
}
}
// Recursively assign IDs and sanitize invalid properties
const sanitizeAndAssignIds = (data: Record<string, unknown>): PenNode => {
const n = { ...data, id: nanoid() } as any
// Convert 'border' → 'strokes' (common model mistake)
if (n.border && !n.strokes) {
n.strokes = [n.border]
delete n.border
}
// Ensure children is a valid array
if (n.children && !Array.isArray(n.children)) {
delete n.children
}
if (Array.isArray(n.children)) {
n.children = n.children
.filter((child: unknown) => child != null && typeof child === 'object')
.map((child: Record<string, unknown>) => sanitizeAndAssignIds(child))
}
return n as PenNode
}
const node = sanitizeAndAssignIds(nodeData as Record<string, unknown>)
// Count total nodes
const countNodes = (n: any): number => {
let c = 1
if (Array.isArray(n.children)) for (const ch of n.children) c += countNodes(ch)
return c
}
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 = []
}
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)
}
}
insertRecursive(node, args.parent)
// Track root-level insert to prevent duplicates
if (args.parent === null) {
this.rootInsertId = node.id
}
// Auto-zoom to show the new design
try {
const { zoomToFitContent } = await import('@/canvas/skia-engine-ref')
setTimeout(() => zoomToFitContent(), 300)
} catch { /* ignore */ }
return {
success: true,
data: {
id: node.id,
nodesCreated: totalNodes,
message: `Created ${totalNodes} nodes successfully. Do NOT retry or create again.`,
},
}
}
private async handleUpdateNode(
args: { id: string; data: Record<string, unknown> },
): Promise<ToolResult> {
const { useDocumentStore } = await import('@/stores/document-store')
const docStore = useDocumentStore.getState()
const existing = docStore.getNodeById(args.id)
if (!existing) {
return { success: false, error: `Node not found: ${args.id}` }
}
docStore.updateNode(args.id, args.data as Partial<PenNode>)
return { success: true }
}
private async handleDeleteNode(args: { id: string }): Promise<ToolResult> {
const { useDocumentStore } = await import('@/stores/document-store')
const docStore = useDocumentStore.getState()
const existing = docStore.getNodeById(args.id)
if (!existing) {
return { success: false, error: `Node not found: ${args.id}` }
}
docStore.removeNode(args.id)
return { success: true }
}
}

View file

@ -0,0 +1,103 @@
import { z } from 'zod'
import { createToolRegistry } from '@zseven-w/agent'
import type { AuthLevel } from '@zseven-w/agent'
const TOOL_AUTH_MAP: Record<string, AuthLevel> = {
// read
batch_get: 'read',
snapshot_layout: 'read',
get_selection: 'read',
get_variables: 'read',
find_empty_space: 'read',
get_design_prompt: 'read',
list_theme_presets: 'read',
get_design_md: 'read',
// create
insert_node: 'create',
add_page: 'create',
duplicate_page: 'create',
import_svg: 'create',
copy_node: 'create',
save_theme_preset: 'create',
generate_design: 'create',
// modify
update_node: 'modify',
replace_node: 'modify',
move_node: 'modify',
set_variables: 'modify',
set_themes: 'modify',
load_theme_preset: 'modify',
rename_page: 'modify',
reorder_page: 'modify',
batch_design: 'modify',
set_design_md: 'modify',
export_design_md: 'modify',
// delete
delete_node: 'delete',
remove_page: 'delete',
}
/**
* Create a tool registry pre-loaded with MVP design tools.
* Tool execute functions are NOT provided they run on the client via ToolExecutor.
*/
export function createDesignToolRegistry() {
const registry = createToolRegistry()
// MVP tools (Phase 1: 6 tools)
registry.register({
name: 'batch_get',
description: 'Get nodes by IDs or search patterns from the document tree',
level: TOOL_AUTH_MAP.batch_get,
schema: z.object({
ids: z.array(z.string()).optional().describe('Node IDs to retrieve'),
patterns: z.array(z.string()).optional().describe('Search patterns to match'),
}),
})
registry.register({
name: 'snapshot_layout',
description: 'Get a compact layout snapshot of the current page showing node positions and sizes',
level: TOOL_AUTH_MAP.snapshot_layout,
schema: z.object({
pageId: z.string().optional(),
}),
})
// Design creation — delegates to the full internal pipeline (orchestrator + sub-agents)
registry.register({
name: 'generate_design',
description: 'Generate a complete design on the canvas. Pass a natural language description. The pipeline handles layout, styling, icons, and rendering. Always use this for creating designs.',
level: TOOL_AUTH_MAP.generate_design,
schema: z.object({
prompt: z.string().describe('Natural language description of the design, e.g. "a modern mobile login screen with email, password, login button, and social login"'),
}),
})
// Modification tools — for editing existing designs
registry.register({
name: 'update_node',
description: 'Update properties of an existing node by ID',
level: TOOL_AUTH_MAP.update_node,
schema: z.object({
id: z.string().describe('Node ID to update'),
data: z.record(z.unknown()).describe('Properties to update'),
}),
})
registry.register({
name: 'delete_node',
description: 'Delete a node from the document by ID',
level: TOOL_AUTH_MAP.delete_node,
schema: z.object({
id: z.string().describe('Node ID to delete'),
}),
})
return registry
}
export { TOOL_AUTH_MAP }

View file

@ -59,6 +59,7 @@ Add a 1-2 sentence description AFTER the JSON block, not before.
NEVER describe what you "would" create ALWAYS output the actual JSON immediately.
NEVER output HTML, CSS, or React code ONLY PenNode JSON.
NEVER say "I will create..." START DIRECTLY WITH <step>.
NEVER use "OpenPencil", "Pencil", or the tool name as brand/app name in designs. Use generic placeholders like "AppName", "Acme", or contextually relevant names.
You may include 1-2 brief <step> tags before the JSON (optional, keep them SHORT).
When a user asks non-design questions (explain, suggest colors, give advice), respond in text.`

View file

@ -108,6 +108,25 @@ export async function* streamChat(
? AbortSignal.any([controller.signal, abortSignal])
: controller.signal
// For builtin provider, attach API key and config from agent settings store
let builtinFields: Record<string, unknown> = {}
if (provider === 'builtin') {
const { useAgentSettingsStore } = await import('@/stores/agent-settings-store')
const { useAIStore } = await import('@/stores/ai-store')
const currentModel = useAIStore.getState().model
if (currentModel.startsWith('builtin:')) {
const bpId = currentModel.split(':')[1]
const bp = useAgentSettingsStore.getState().builtinProviders.find((p) => p.id === bpId)
if (bp) {
builtinFields = {
builtinApiKey: bp.apiKey,
builtinBaseURL: bp.baseURL,
builtinType: bp.type,
}
}
}
}
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -123,6 +142,7 @@ export async function* streamChat(
thinkingMode: options?.thinkingMode,
thinkingBudgetTokens: options?.thinkingBudgetTokens,
effort: options?.effort,
...builtinFields,
}),
signal: fetchSignal,
})

View file

@ -16,6 +16,7 @@ export interface ChatMessage {
timestamp: number
isStreaming?: boolean
attachments?: ChatAttachment[]
source?: string
}
export interface AIDesignRequest {

View file

@ -0,0 +1,454 @@
// apps/web/src/services/ai/code-generation-pipeline.ts
import type { PenNode } from '@zseven-w/pen-types'
import type {
Framework,
CodePlanFromAI,
CodeExecutionPlan,
ExecutableChunk,
PlannedChunk,
ChunkResult,
ChunkContract,
ChunkStatus,
CodeGenProgress,
} from '@zseven-w/pen-codegen'
import { validateContract, sanitizeName } from '@zseven-w/pen-codegen'
import { buildPlanningPrompt, buildChunkPrompt, buildAssemblyPrompt } from './codegen-prompts'
import { streamChat } from './ai-service'
// ── Exported helpers (tested independently) ──
/**
* Hydrate a CodePlanFromAI with actual node data.
* Strips chunks whose nodeIds don't match any input nodes.
*/
export function hydratePlan(plan: CodePlanFromAI, nodes: PenNode[]): CodeExecutionPlan {
const nodeMap = new Map<string, PenNode>()
function indexNodes(list: PenNode[]) {
for (const n of list) {
nodeMap.set(n.id, n)
const children = (n as { children?: PenNode[] }).children
if (children) indexNodes(children)
}
}
indexNodes(nodes)
const orders = computeExecutionOrder(plan.chunks)
const chunks: ExecutableChunk[] = plan.chunks
.map(chunk => {
const resolved = chunk.nodeIds
.map(id => nodeMap.get(id))
.filter((n): n is PenNode => n !== undefined)
if (resolved.length === 0) return null
return {
...chunk,
nodes: resolved,
order: orders.get(chunk.id) ?? 0,
depContracts: [] as ChunkContract[],
}
})
.filter((c): c is ExecutableChunk => c !== null)
return {
chunks,
sharedStyles: plan.sharedStyles,
rootLayout: plan.rootLayout,
}
}
/**
* Compute execution order from dependency graph.
* Chunks with no deps get order 0. Dependent chunks get max(dep orders) + 1.
*/
export function computeExecutionOrder(chunks: PlannedChunk[]): Map<string, number> {
const orders = new Map<string, number>()
function resolve(id: string, visited: Set<string>): number {
if (orders.has(id)) return orders.get(id)!
if (visited.has(id)) return 0 // cycle guard
visited.add(id)
const chunk = chunks.find(c => c.id === id)
if (!chunk || chunk.dependencies.length === 0) {
orders.set(id, 0)
return 0
}
const maxDep = Math.max(...chunk.dependencies.map(depId => resolve(depId, visited)))
const order = maxDep + 1
orders.set(id, order)
return order
}
for (const chunk of chunks) {
resolve(chunk.id, new Set())
}
return orders
}
/**
* Parse a chunk generation response into code + contract.
* Looks for ---CONTRACT--- separator.
*/
export function parseChunkResponse(response: string, chunkId: string): ChunkResult {
// Strategy 1: explicit ---CONTRACT--- separator
const separator = '---CONTRACT---'
const sepIdx = response.indexOf(separator)
if (sepIdx !== -1) {
const code = cleanCode(response.slice(0, sepIdx))
const contractStr = response.slice(sepIdx + separator.length).trim()
const contract = tryParseContract(contractStr, chunkId)
if (contract) return { chunkId, code, contract }
}
// Strategy 2: find a JSON block containing "componentName" (AI often wraps in ```json)
const contractJsonMatch = response.match(/```json\s*\n([\s\S]*?)\n\s*```/)
if (contractJsonMatch) {
const jsonStr = contractJsonMatch[1].trim()
if (jsonStr.includes('"componentName"')) {
const contract = tryParseContract(jsonStr, chunkId)
if (contract) {
// Everything before the JSON block is code
const jsonBlockStart = response.indexOf(contractJsonMatch[0])
const code = cleanCode(response.slice(0, jsonBlockStart))
return { chunkId, code, contract }
}
}
}
// Strategy 3: find last JSON object with "componentName" in the response
const lastJsonMatch = response.match(/(\{[^{}]*"componentName"[^{}]*\})\s*$/)
if (lastJsonMatch) {
const contract = tryParseContract(lastJsonMatch[1], chunkId)
if (contract) {
const jsonStart = response.lastIndexOf(lastJsonMatch[1])
const code = cleanCode(response.slice(0, jsonStart))
return { chunkId, code, contract }
}
}
// Strategy 4: infer contract from code (extract component name from export)
const code = cleanCode(response)
const inferredContract = inferContractFromCode(code, chunkId)
return { chunkId, code, contract: inferredContract }
}
function tryParseContract(str: string, chunkId: string): ChunkContract | null {
try {
// Strip markdown fences if present
const cleaned = str.replace(/^```\w*\n?/gm, '').replace(/```\s*$/gm, '').trim()
const parsed = JSON.parse(cleaned) as ChunkContract
if (parsed.componentName) {
parsed.chunkId = chunkId
parsed.exportedProps = parsed.exportedProps ?? []
parsed.slots = parsed.slots ?? []
parsed.cssClasses = parsed.cssClasses ?? []
parsed.cssVariables = parsed.cssVariables ?? []
parsed.imports = parsed.imports ?? []
return parsed
}
} catch { /* not valid JSON */ }
return null
}
function inferContractFromCode(code: string, chunkId: string): ChunkContract {
const isSFC = code.includes('<script') || code.includes('<template') || code.includes('<style')
// Try to extract component name from export statements
// Skip export let/const for SFC (Svelte uses them for props, not component names)
const exportMatch = code.match(/export\s+default\s+function\s+(\w+)/)
?? code.match(/export\s+function\s+([A-Z]\w*)/) // only PascalCase functions
?? (!isSFC ? code.match(/export\s+default\s+class\s+(\w+)/) : null)
?? code.match(/fun\s+([A-Z]\w*)\s*\(/) // Kotlin (PascalCase only)
?? code.match(/struct\s+(\w+)\s*:\s*View/) // SwiftUI
?? code.match(/class\s+(\w+)\s+extends/) // Dart/Flutter
const componentName = exportMatch?.[1] ?? ''
// Extract imports
const importMatches = [...code.matchAll(/import\s+.*?from\s+['"](.+?)['"]/g)]
const imports = importMatches.map(m => ({
source: m[1],
specifiers: [] as string[],
}))
return {
chunkId,
componentName,
exportedProps: [],
slots: [],
cssClasses: [],
cssVariables: [],
imports,
}
}
function cleanCode(raw: string): string {
return raw
.replace(/^```\w*\n?/gm, '')
.replace(/```\s*$/gm, '')
.trim()
}
// ── Main pipeline ──
export async function generateCode(
nodes: PenNode[],
framework: Framework,
variables: Record<string, unknown> | undefined,
onProgress: (event: CodeGenProgress) => void,
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
): Promise<string> {
// ── Step 1: Planning ──
onProgress({ step: 'planning', status: 'running' })
let planFromAI: CodePlanFromAI
try {
planFromAI = await runPlanning(nodes, framework, model, provider, abortSignal)
onProgress({ step: 'planning', status: 'done', plan: planFromAI })
} catch (err) {
if (abortSignal?.aborted) throw err
// Retry once with stricter prompt
try {
planFromAI = await runPlanning(nodes, framework, model, provider, abortSignal, true)
onProgress({ step: 'planning', status: 'done', plan: planFromAI })
} catch (retryErr) {
const msg = retryErr instanceof Error ? retryErr.message : 'Planning failed'
onProgress({ step: 'planning', status: 'failed', error: msg })
onProgress({ step: 'error', message: msg })
throw retryErr
}
}
// Hydrate plan with actual node data
const execPlan = hydratePlan(planFromAI, nodes)
if (execPlan.chunks.length === 0) {
const msg = 'Planning produced no valid chunks'
onProgress({ step: 'planning', status: 'failed', error: msg })
onProgress({ step: 'error', message: msg })
throw new Error(msg)
}
// Initialize all chunks as pending
for (const chunk of execPlan.chunks) {
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: 'pending' })
}
// ── Step 2: Parallel Chunk Generation ──
const results = new Map<string, ChunkResult>()
const statuses = new Map<string, ChunkStatus>()
// Group by execution order
const maxOrder = Math.max(...execPlan.chunks.map(c => c.order))
for (let order = 0; order <= maxOrder; order++) {
if (abortSignal?.aborted) throw new Error('Aborted')
const batch = execPlan.chunks.filter(c => c.order === order)
const batchPromises = batch.map(async (chunk) => {
// Check if dependencies failed
const depsFailed = chunk.dependencies.some(depId => statuses.get(depId) === 'failed')
if (depsFailed) {
statuses.set(chunk.id, 'skipped')
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: 'skipped' })
return
}
// Collect dependency contracts
const depContracts: ChunkContract[] = chunk.dependencies
.map(depId => results.get(depId)?.contract)
.filter((c): c is ChunkContract => c !== undefined && c.componentName !== '')
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: 'running' })
try {
const result = await runChunkGeneration(
chunk.nodes, framework, chunk.suggestedComponentName, depContracts, chunk.id, model, provider, abortSignal,
)
// Ensure componentName is valid PascalCase — AI may return kebab-case or empty
if (!result.contract.componentName || !/^[A-Z][a-zA-Z0-9]*$/.test(result.contract.componentName)) {
result.contract.componentName = sanitizeName(chunk.suggestedComponentName)
}
const validation = validateContract(result)
if (validation.valid) {
results.set(chunk.id, result)
statuses.set(chunk.id, 'done')
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: 'done', result })
} else {
// Contract invalid — mark degraded
results.set(chunk.id, result)
statuses.set(chunk.id, 'degraded')
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: 'degraded', result })
}
} catch (err) {
// Retry once
try {
const result = await runChunkGeneration(
chunk.nodes, framework, chunk.suggestedComponentName, depContracts, chunk.id, model, provider, abortSignal,
)
if (!result.contract.componentName || !/^[A-Z][a-zA-Z0-9]*$/.test(result.contract.componentName)) {
result.contract.componentName = sanitizeName(chunk.suggestedComponentName)
}
results.set(chunk.id, result)
const validation = validateContract(result)
statuses.set(chunk.id, validation.valid ? 'done' : 'degraded')
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: statuses.get(chunk.id)!, result })
} catch (retryErr) {
statuses.set(chunk.id, 'failed')
const msg = retryErr instanceof Error ? retryErr.message : 'Chunk generation failed'
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: 'failed', error: msg })
}
}
})
await Promise.all(batchPromises)
}
// ── Step 3: Assembly ──
onProgress({ step: 'assembly', status: 'running' })
const chunkInputs = execPlan.chunks.map(chunk => {
const status = statuses.get(chunk.id)
const result = results.get(chunk.id)
return {
chunkId: chunk.id,
name: chunk.name,
code: result?.code ?? '',
contract: result?.contract,
status: (status === 'done' ? 'successful' : status === 'degraded' ? 'degraded' : 'failed') as 'successful' | 'degraded' | 'failed',
}
})
const hasAnyCode = chunkInputs.some(c => c.code.length > 0)
if (!hasAnyCode) {
const msg = 'All chunks failed — no code to assemble'
onProgress({ step: 'assembly', status: 'failed', error: msg })
onProgress({ step: 'error', message: msg })
throw new Error(msg)
}
let finalCode: string
let degraded = chunkInputs.some(c => c.status !== 'successful')
try {
finalCode = await runAssembly(chunkInputs, planFromAI, framework, variables, model, provider, abortSignal)
onProgress({ step: 'assembly', status: 'done' })
} catch {
// Retry once
try {
finalCode = await runAssembly(chunkInputs, planFromAI, framework, variables, model, provider, abortSignal)
onProgress({ step: 'assembly', status: 'done' })
} catch {
// Best-effort fallback: concatenate chunk codes
finalCode = chunkInputs
.filter(c => c.code)
.map(c => `// ── ${c.name} (${c.status}) ──\n\n${c.code}`)
.join('\n\n')
degraded = true
onProgress({ step: 'assembly', status: 'failed', error: 'Assembly failed — showing concatenated chunks' })
}
}
onProgress({ step: 'complete', finalCode, degraded })
return finalCode
}
// ── Internal AI call wrappers ──
/**
* Collect all text content from a streamChat call.
* Adapts the AIStreamChunk-based generator to return a plain string.
* Throws on error chunks from the stream.
*/
async function collectStreamText(
systemPrompt: string,
userMessage: string,
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
): Promise<string> {
let fullResponse = ''
for await (const chunk of streamChat(
systemPrompt,
[{ role: 'user', content: userMessage }],
model,
undefined, // options
provider,
abortSignal,
)) {
if (chunk.type === 'text') {
fullResponse += chunk.content
} else if (chunk.type === 'error') {
throw new Error(chunk.content)
}
}
return fullResponse
}
async function runPlanning(
nodes: PenNode[],
framework: Framework,
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
strict?: boolean,
): Promise<CodePlanFromAI> {
const { system, user } = buildPlanningPrompt(nodes, framework)
const systemPrompt = strict
? system + '\n\nCRITICAL: Respond with ONLY valid JSON. No markdown, no explanation.'
: system
const fullResponse = await collectStreamText(systemPrompt, user, model, provider, abortSignal)
// Extract JSON from response
const jsonMatch = fullResponse.match(/\{[\s\S]*\}/)
if (!jsonMatch) throw new Error('No JSON found in planning response')
const plan = JSON.parse(jsonMatch[0]) as CodePlanFromAI
if (!plan.chunks || !plan.rootLayout) {
throw new Error('Planning response missing required fields (chunks, rootLayout)')
}
plan.sharedStyles = plan.sharedStyles ?? []
return plan
}
async function runChunkGeneration(
nodes: PenNode[],
framework: Framework,
suggestedComponentName: string,
depContracts: ChunkContract[],
chunkId: string,
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
): Promise<ChunkResult> {
const { system, user } = buildChunkPrompt(nodes, framework, suggestedComponentName, depContracts)
const fullResponse = await collectStreamText(system, user, model, provider, abortSignal)
return parseChunkResponse(fullResponse, chunkId)
}
async function runAssembly(
chunkResults: { chunkId: string; name: string; code: string; contract?: ChunkContract; status: 'successful' | 'degraded' | 'failed' }[],
plan: CodePlanFromAI,
framework: Framework,
variables: Record<string, unknown> | undefined,
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
): Promise<string> {
const { system, user } = buildAssemblyPrompt(chunkResults, plan, framework, variables)
const fullResponse = await collectStreamText(system, user, model, provider, abortSignal)
return cleanCode(fullResponse)
}

View file

@ -0,0 +1,111 @@
// apps/web/src/services/ai/codegen-prompts.ts
import { getSkillByName } from '@zseven-w/pen-ai-skills'
import type { Framework, ChunkContract, CodePlanFromAI } from '@zseven-w/pen-codegen'
import type { PenNode } from '@zseven-w/pen-types'
import { nodeTreeToSummary } from '@zseven-w/pen-codegen'
function loadSkill(name: string): string {
return getSkillByName(name)?.content ?? ''
}
/**
* Build system prompt for Step 1: planning.
*/
export function buildPlanningPrompt(nodes: PenNode[], framework: Framework): {
system: string
user: string
} {
const planningSkill = loadSkill('codegen-planning')
const summary = nodeTreeToSummary(nodes)
return {
system: planningSkill,
user: [
`Target framework: ${framework}`,
'',
'Node tree:',
summary,
'',
'Analyze this node tree and output a JSON code generation plan.',
].join('\n'),
}
}
/**
* Build system prompt for Step 2: chunk generation.
*/
export function buildChunkPrompt(
nodes: PenNode[],
framework: Framework,
suggestedComponentName: string,
depContracts: ChunkContract[],
): { system: string; user: string } {
const chunkSkill = loadSkill('codegen-chunk')
const frameworkSkill = loadSkill(`codegen-${framework}`)
const depSection = depContracts.length > 0
? [
'',
'## Dependency Contracts',
'The following components are available from upstream chunks. Import and use them:',
'',
...depContracts.map(c =>
`- \`${c.componentName}\` (chunk: ${c.chunkId}): props=[${c.exportedProps.map(p => `${p.name}: ${p.type}`).join(', ')}], slots=[${c.slots.map(s => s.name).join(', ')}]`
),
].join('\n')
: ''
return {
system: [chunkSkill, '', '---', '', frameworkSkill].join('\n'),
user: [
`Generate a ${framework} component named "${suggestedComponentName}".`,
'',
'Nodes (JSON):',
JSON.stringify(nodes, null, 2),
depSection,
'',
'Output the code followed by ---CONTRACT--- and the JSON contract.',
].join('\n'),
}
}
/**
* Build system prompt for Step 3: assembly.
*/
export function buildAssemblyPrompt(
chunkResults: { chunkId: string; name: string; code: string; contract?: ChunkContract; status: 'successful' | 'degraded' | 'failed' }[],
plan: CodePlanFromAI,
framework: Framework,
variables?: Record<string, unknown>,
): { system: string; user: string } {
const assemblySkill = loadSkill('codegen-assembly')
const frameworkSkill = loadSkill(`codegen-${framework}`)
const chunksSection = chunkResults.map(r => {
if (r.status === 'failed') {
return `### ${r.name} (FAILED)\nThis chunk failed to generate. Insert a placeholder comment.`
}
const contractNote = r.status === 'degraded'
? '\n*NOTE: No contract available. Infer component name and imports from the code.*'
: `\nContract: ${JSON.stringify(r.contract)}`
return `### ${r.name} (${r.status})\n\`\`\`\n${r.code}\n\`\`\`${contractNote}`
}).join('\n\n')
return {
system: [assemblySkill, '', '---', '', frameworkSkill].join('\n'),
user: [
`Assemble the following ${framework} code chunks into a single production-ready file.`,
'',
`Root layout: ${JSON.stringify(plan.rootLayout)}`,
`Shared styles: ${JSON.stringify(plan.sharedStyles)}`,
variables ? `Design variables: ${JSON.stringify(variables)}` : '',
'',
'## Chunks',
'',
chunksSection,
'',
'Output ONLY the final assembled source code.',
].filter(Boolean).join('\n'),
}
}

View file

@ -13,6 +13,20 @@ import { appStorage } from '@/utils/app-storage'
const STORAGE_KEY = 'openpencil-agent-settings'
export type BuiltinProviderPreset = 'anthropic' | 'openai' | 'openrouter' | 'deepseek' | 'gemini' | 'minimax' | 'zhipu' | 'kimi' | 'bailian' | 'doubao' | 'xiaomi' | 'modelscope' | 'stepfun' | 'nvidia' | 'custom'
export interface BuiltinProviderConfig {
id: string
displayName: string
type: 'anthropic' | 'openai-compat'
apiKey: string
model: string
baseURL?: string
preset?: BuiltinProviderPreset
maxContextTokens?: number
enabled: boolean
}
interface PersistedState {
providers: Record<AIProviderType, AIProviderConfig>
mcpIntegrations: MCPCliIntegration[]
@ -22,6 +36,9 @@ interface PersistedState {
imageGenProfiles: ImageGenProfile[]
activeImageGenProfileId: string | null
openverseOAuth: { clientId: string; clientSecret: string } | null
builtinProviders: BuiltinProviderConfig[]
teamEnabled: boolean
teamDesignModel: string | null
}
interface AgentSettingsState extends PersistedState {
@ -49,6 +66,11 @@ interface AgentSettingsState extends PersistedState {
setActiveImageGenProfile: (id: string | null) => void
getActiveImageGenProfile: () => ImageGenProfile | null
setOpenverseOAuth: (oauth: { clientId: string; clientSecret: string } | null) => void
addBuiltinProvider: (config: Omit<BuiltinProviderConfig, 'id'>) => string
updateBuiltinProvider: (id: string, updates: Partial<BuiltinProviderConfig>) => void
removeBuiltinProvider: (id: string) => void
setTeamEnabled: (enabled: boolean) => void
setTeamDesignModel: (model: string | null) => void
persist: () => void
hydrate: () => void
}
@ -109,6 +131,9 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
imageGenProfiles: [],
activeImageGenProfileId: null,
openverseOAuth: null,
builtinProviders: [],
teamEnabled: false,
teamDesignModel: null,
dialogOpen: false,
isHydrated: false,
mcpServerRunning: false,
@ -201,12 +226,34 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
setOpenverseOAuth: (oauth) => set({ openverseOAuth: oauth }),
addBuiltinProvider: (config) => {
const id = `bp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
const newProvider: BuiltinProviderConfig = { ...config, id }
set((s) => ({ builtinProviders: [...s.builtinProviders, newProvider] }))
return id
},
updateBuiltinProvider: (id, updates) =>
set((s) => ({
builtinProviders: s.builtinProviders.map((p) =>
p.id === id ? { ...p, ...updates } : p,
),
})),
removeBuiltinProvider: (id) =>
set((s) => ({
builtinProviders: s.builtinProviders.filter((p) => p.id !== id),
})),
setTeamEnabled: (teamEnabled) => set({ teamEnabled }),
setTeamDesignModel: (teamDesignModel) => set({ teamDesignModel }),
persist: () => {
try {
const { providers, mcpIntegrations, mcpTransportMode, mcpHttpPort, imageGenConfig, imageGenProfiles, activeImageGenProfileId, openverseOAuth } = get()
const { providers, mcpIntegrations, mcpTransportMode, mcpHttpPort, imageGenConfig, imageGenProfiles, activeImageGenProfileId, openverseOAuth, builtinProviders, teamEnabled, teamDesignModel } = get()
appStorage.setItem(
STORAGE_KEY,
JSON.stringify({ providers, mcpIntegrations, mcpTransportMode, mcpHttpPort, imageGenConfig, imageGenProfiles, activeImageGenProfileId, openverseOAuth }),
JSON.stringify({ providers, mcpIntegrations, mcpTransportMode, mcpHttpPort, imageGenConfig, imageGenProfiles, activeImageGenProfileId, openverseOAuth, builtinProviders, teamEnabled, teamDesignModel }),
)
} catch {
// ignore
@ -257,6 +304,15 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
set({ imageGenProfiles: [migrated], activeImageGenProfileId: migrated.id })
}
if (data.openverseOAuth !== undefined) set({ openverseOAuth: data.openverseOAuth })
if (Array.isArray((data as Record<string, unknown>).builtinProviders)) {
set({ builtinProviders: (data as Record<string, unknown>).builtinProviders as BuiltinProviderConfig[] })
}
if ((data as Record<string, unknown>).teamEnabled !== undefined) {
set({ teamEnabled: (data as Record<string, unknown>).teamEnabled as boolean })
}
if ((data as Record<string, unknown>).teamDesignModel !== undefined) {
set({ teamDesignModel: (data as Record<string, unknown>).teamDesignModel as string | null })
}
} catch {
// ignore
} finally {

View file

@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { ChatMessage, ChatAttachment } from '@/services/ai/ai-types'
import type { ModelGroup } from '@/types/agent-settings'
import type { ToolCallBlockData } from '@/components/panels/tool-call-block'
import { appStorage } from '@/utils/app-storage'
export type PanelCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
@ -103,9 +104,13 @@ interface AIState {
chatTitle: string
generationProgress: { current: number; total: number } | null
concurrency: number
toolCallBlocks: ToolCallBlockData[]
pendingAttachments: ChatAttachment[]
abortController: AbortController | null
addToolCallBlock: (block: ToolCallBlockData) => void
updateToolCallBlock: (id: string, updates: Partial<ToolCallBlockData>) => void
clearToolCallBlocks: () => void
setConcurrency: (n: number) => void
setChatTitle: (title: string) => void
setGenerationProgress: (progress: { current: number; total: number } | null) => void
@ -151,9 +156,19 @@ export const useAIStore = create<AIState>((set, get) => ({
chatTitle: 'New Chat',
concurrency: 1,
generationProgress: null,
toolCallBlocks: [],
pendingAttachments: [],
abortController: null,
addToolCallBlock: (block) =>
set((s) => ({ toolCallBlocks: [...s.toolCallBlocks, block] })),
updateToolCallBlock: (id, updates) =>
set((s) => ({
toolCallBlocks: s.toolCallBlocks.map((b) =>
b.id === id ? { ...b, ...updates } : b,
),
})),
clearToolCallBlocks: () => set({ toolCallBlocks: [] }),
setConcurrency: (n) => {
const clamped = Math.max(1, Math.min(6, n))
writeStoredConcurrency(clamped)
@ -217,7 +232,7 @@ export const useAIStore = create<AIState>((set, get) => ({
setAvailableModels: (availableModels) => set({ availableModels }),
setModelGroups: (modelGroups) => set({ modelGroups }),
setLoadingModels: (isLoadingModels) => set({ isLoadingModels }),
clearMessages: () => set({ messages: [], chatTitle: 'New Chat' }),
clearMessages: () => set({ messages: [], chatTitle: 'New Chat', toolCallBlocks: [] }),
setPanelCorner: (panelCorner) => {
set({ panelCorner })

View file

@ -3,6 +3,7 @@ import { nanoid } from 'nanoid'
import type { PenDocument, PenNode, GroupNode, RefNode } from '@/types/pen'
import type { VariableDefinition } from '@/types/variables'
import { normalizePenDocument } from '@/utils/normalize-pen-file'
import { useHistoryStore } from '@/stores/history-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { getDefaultTheme } from '@/variables/resolve-variables'
@ -678,7 +679,9 @@ export const useDocumentStore = create<DocumentStoreState>(
applyExternalDocument: (doc) => {
// Push current state to history so MCP changes are undoable
useHistoryStore.getState().pushState(get().document)
const migrated = ensureDocumentNodeIds(migrateToPages(doc))
// Normalize external document (fill object→array, text→content, etc.)
const normalized = normalizePenDocument(doc)
const migrated = ensureDocumentNodeIds(migrateToPages(normalized))
// Preserve activePageId if page still exists
const activePageId = useCanvasStore.getState().activePageId
const pageExists = migrated.pages?.some((p) => p.id === activePageId)

View file

@ -35,6 +35,8 @@ export interface GroupedModel {
displayName: string
description: string
provider: AIProviderType
/** When set, this model came from a built-in provider (API key) rather than a CLI tool */
builtinProviderId?: string
}
export interface ModelGroup {

View file

@ -85,37 +85,49 @@
},
"apps/cli": {
"name": "@zseven-w/openpencil",
"version": "0.5.1",
"version": "0.5.2",
"bin": {
"op": "dist/openpencil-cli.cjs",
},
},
"apps/desktop": {
"name": "@zseven-w/desktop",
"version": "0.5.1",
"version": "0.5.2",
},
"apps/web": {
"name": "@zseven-w/web",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@zseven-w/agent": "workspace:*",
"@zseven-w/pen-ai-skills": "workspace:*",
"@zseven-w/pen-codegen": "workspace:*",
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-figma": "workspace:*",
"@zseven-w/pen-renderer": "workspace:*",
"@zseven-w/pen-types": "workspace:*",
"zod": "^3.24",
},
},
"packages/agent": {
"name": "@zseven-w/agent",
"version": "0.5.3",
"dependencies": {
"@ai-sdk/anthropic": "^3.0",
"@ai-sdk/openai": "^3.0",
"ai": "^6.0",
"zod": "^3.24",
},
},
"packages/pen-ai-skills": {
"name": "@zseven-w/pen-ai-skills",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"gray-matter": "^4.0.3",
},
},
"packages/pen-codegen": {
"name": "@zseven-w/pen-codegen",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-types": "workspace:*",
@ -126,7 +138,7 @@
},
"packages/pen-core": {
"name": "@zseven-w/pen-core",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@zseven-w/pen-types": "workspace:*",
"nanoid": "^5.1.6",
@ -138,7 +150,7 @@
},
"packages/pen-figma": {
"name": "@zseven-w/pen-figma",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@zseven-w/pen-types": "workspace:*",
"fzstd": "^0.1.1",
@ -152,7 +164,7 @@
},
"packages/pen-renderer": {
"name": "@zseven-w/pen-renderer",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-types": "workspace:*",
@ -169,7 +181,7 @@
},
"packages/pen-sdk": {
"name": "@zseven-w/pen-sdk",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@zseven-w/pen-codegen": "workspace:*",
"@zseven-w/pen-core": "workspace:*",
@ -183,7 +195,7 @@
},
"packages/pen-types": {
"name": "@zseven-w/pen-types",
"version": "0.5.1",
"version": "0.5.2",
"devDependencies": {
"typescript": "^5.7.2",
},
@ -194,6 +206,16 @@
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "https://registry.npmmirror.com/@ai-sdk/anthropic/-/anthropic-3.0.64.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.80", "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-3.0.80.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uM7kpZB5l977lW7+2X1+klBUxIZQ78+1a9jHlaHFEzcOcmmslTl3sdP0QqfuuBcO0YBM2gwOiqVdp8i4TRQYcw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-3.0.48.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.8.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-4.0.21.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.81", "", { "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-CBeebgibBEN/DWOQGZN67vhuTG55RbI1hlsFSSoZ4uA/Io3lw04eHTE2ISCmdbqyJaefYTt6GKZei1nP0TQMNw=="],
"@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=="],
@ -464,6 +486,8 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.6", "", {}, "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.0.tgz", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@ -630,6 +654,8 @@
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
@ -774,6 +800,8 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.1.0.tgz", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@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=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@ -794,6 +822,8 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"@zseven-w/agent": ["@zseven-w/agent@workspace:packages/agent"],
"@zseven-w/desktop": ["@zseven-w/desktop@workspace:apps/desktop"],
"@zseven-w/openpencil": ["@zseven-w/openpencil@workspace:apps/cli"],
@ -822,6 +852,8 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@6.0.138", "https://registry.npmmirror.com/ai/-/ai-6.0.138.tgz", { "dependencies": { "@ai-sdk/gateway": "3.0.80", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-49OfPe0f5uxJ6jUdA5BBXjIinP6+ZdYfAtpF2aEH64GA5wPcxH2rf/TBUQQ0bbamBz/D+TLMV18xilZqOC+zaA=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@ -1320,6 +1352,8 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@ -1872,12 +1906,14 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"@anthropic-ai/claude-agent-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@ -1906,6 +1942,8 @@
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
"@github/copilot-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@ -1914,6 +1952,8 @@
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
@ -1934,18 +1974,12 @@
"@tanstack/router-devtools-core/@tanstack/router-core": ["@tanstack/router-core@1.168.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-9wHR7syfY7y/qrvTvv8bugh6mrKk58TuiSQp44nbGW0BpE2+IIta1DBeL5jHr9AD1a+c5fVKSu/JXsKeniUc9w=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@tanstack/start-plugin-core/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.40", "", {}, "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w=="],
"@tanstack/start-plugin-core/@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.1", "@tanstack/router-generator": "1.166.15", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.1", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-33KSngqHzFTMFz7rPIIeroVi0x+GzbCOch1goEdH71s1MbdyNhCTPuzKFO3QOkT+aqirCXkCgSgHVoU//Zletw=="],
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

View file

@ -0,0 +1,842 @@
# Agent Team Web Integration — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable model routing via Agent Team — fast model for chat (lead), capable model for design (member).
**Architecture:** SDK adds `source` field to AgentEvent + `member_start`/`member_end` events. Server conditionally uses `createTeam` when `members` array is present. Client adds team toggle + design model selector in settings.
**Tech Stack:** TypeScript, Vercel AI SDK, Zustand, React, i18next
**Spec:** `docs/superpowers/specs/2026-03-28-agent-team-web-integration.md`
---
### Task 1: Add `source` and team events to AgentEvent type
**Files:**
- Modify: `packages/agent/src/streaming/types.ts`
- [ ] **Step 1: Update AgentEvent type**
Add optional `source` to existing variants and two new team events:
```typescript
import type { AuthLevel } from '../tools/types'
export type AgentEvent =
| { type: 'thinking'; content: string; source?: string }
| { type: 'text'; content: string; source?: string }
| { type: 'tool_call'; id: string; name: string; args: unknown; level: AuthLevel; source?: string }
| { type: 'tool_result'; id: string; name: string; result: { success: boolean; data?: unknown; error?: string }; source?: string }
| { type: 'turn'; turn: number; maxTurns: number; source?: string }
| { type: 'done'; totalTurns: number; source?: string }
| { type: 'error'; message: string; fatal: boolean; source?: string }
| { type: 'abort' }
| { type: 'member_start'; memberId: string; task: string }
| { type: 'member_end'; memberId: string; result: string }
```
- [ ] **Step 2: Verify types compile**
Run: `npx tsc --noEmit --project packages/agent/tsconfig.json`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add packages/agent/src/streaming/types.ts
git commit -m "feat(agent): add source field and team events to AgentEvent type"
```
---
### Task 2: Update SSE encoder/decoder tests and implementation
**Files:**
- Modify: `packages/agent/__tests__/streaming/sse-encoder.test.ts`
- Modify: `packages/agent/__tests__/streaming/sse-decoder.test.ts`
- Verify: `packages/agent/src/streaming/sse-encoder.ts` (no change needed — generic JSON serialization)
- Verify: `packages/agent/src/streaming/sse-decoder.ts` (no change needed — generic JSON parsing)
- [ ] **Step 1: Add encoder tests for new event types**
Append to `packages/agent/__tests__/streaming/sse-encoder.test.ts`:
```typescript
it('encodes a text event with source', () => {
const event: AgentEvent = { type: 'text', content: 'hello', source: 'lead' }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: text\ndata: {"content":"hello","source":"lead"}\n\n')
})
it('encodes a member_start event', () => {
const event: AgentEvent = { type: 'member_start', memberId: 'designer', task: 'Create landing page' }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: member_start\ndata: {"memberId":"designer","task":"Create landing page"}\n\n')
})
it('encodes a member_end event', () => {
const event: AgentEvent = { type: 'member_end', memberId: 'designer', result: 'Done' }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: member_end\ndata: {"memberId":"designer","result":"Done"}\n\n')
})
```
- [ ] **Step 2: Add decoder tests for new event types**
Append to `packages/agent/__tests__/streaming/sse-decoder.test.ts`:
```typescript
it('decodes a text event with source', () => {
const raw = 'event: text\ndata: {"content":"hello","source":"designer"}'
const event = decodeAgentEvent(raw)
expect(event).toEqual({ type: 'text', content: 'hello', source: 'designer' })
})
it('decodes a member_start event', () => {
const raw = 'event: member_start\ndata: {"memberId":"designer","task":"Build UI"}'
const event = decodeAgentEvent(raw)
expect(event).toEqual({ type: 'member_start', memberId: 'designer', task: 'Build UI' })
})
it('decodes a member_end event', () => {
const raw = 'event: member_end\ndata: {"memberId":"designer","result":"Done"}'
const event = decodeAgentEvent(raw)
expect(event).toEqual({ type: 'member_end', memberId: 'designer', result: 'Done' })
})
```
- [ ] **Step 3: Run tests**
Run: `cd packages/agent && npx vitest run __tests__/streaming/`
Expected: all tests PASS (encoder/decoder are generic JSON — no code changes needed)
- [ ] **Step 4: Commit**
```bash
git add packages/agent/__tests__/streaming/
git commit -m "test(agent): add SSE encoder/decoder tests for source and team events"
```
---
### Task 3: Inject source and team events in agent-team.ts
**Files:**
- Modify: `packages/agent/src/agent-team.ts`
- Modify: `packages/agent/__tests__/agent-team.test.ts`
- [ ] **Step 1: Write failing test for source injection and team events**
Append to `packages/agent/__tests__/agent-team.test.ts`:
```typescript
it('does not mutate the caller tools registry', () => {
const tools = createToolRegistry()
const initialCount = tools.list().length
createTeam({
lead: { provider: mockProvider, tools, systemPrompt: 'Lead' },
members: [{ id: 'worker', provider: mockProvider, tools: createToolRegistry(), systemPrompt: 'Worker' }],
})
expect(tools.list().length).toBe(initialCount)
})
```
- [ ] **Step 2: Run test to verify it passes** (already fixed in prior P1 commit)
Run: `cd packages/agent && npx vitest run __tests__/agent-team.test.ts`
Expected: PASS
- [ ] **Step 3: Update agent-team.ts with source injection and team suffix**
Replace the `run` generator and team setup in `packages/agent/src/agent-team.ts`:
```typescript
import type { ModelMessage } from 'ai'
import type { AgentProvider } from './providers/types'
import type { ToolRegistry } from './tools/tool-registry'
import type { AgentEvent } from './streaming/types'
import type { ContextStrategy } from './context/types'
import type { ToolResult } from './tools/types'
import { createAgent } from './agent-loop'
import { createDelegateTool } from './tools/delegate'
import { createToolRegistry } from './tools/tool-registry'
export interface TeamMemberConfig {
id: string
provider: AgentProvider
tools: ToolRegistry
systemPrompt: string
maxTurns?: number
contextStrategy?: ContextStrategy
}
export interface TeamConfig {
lead: {
provider: AgentProvider
tools: ToolRegistry
systemPrompt: string
maxTurns?: number
contextStrategy?: ContextStrategy
}
members: TeamMemberConfig[]
}
export interface AgentTeam {
run(messages: ModelMessage[]): AsyncGenerator<AgentEvent>
abort(): void
resolveToolResult(toolCallId: string, result: ToolResult): void
}
/** Build a team-awareness suffix for the lead's system prompt. */
function buildTeamSuffix(members: TeamMemberConfig[]): string {
const lines = members.map(m => {
const desc = m.systemPrompt.split('\n')[0] || 'Available for sub-tasks'
return `- **${m.id}**: ${desc}`
})
return `\n\n## Team Members\nYou have team members available via the \`delegate\` tool:\n${lines.join('\n')}\n\nWhen the user's request involves specialized work, delegate to the appropriate member.\nHandle conversation, planning, and simple queries yourself.`
}
export function createTeam(config: TeamConfig): AgentTeam {
const parentAbort = new AbortController()
const memberIds = config.members.map(m => m.id)
// Clone tools to avoid mutating the caller's registry
const leadTools = createToolRegistry()
for (const tool of config.lead.tools.list()) {
leadTools.register(tool)
}
leadTools.register(createDelegateTool(memberIds))
const leadAgent = createAgent({
provider: config.lead.provider,
tools: leadTools,
systemPrompt: config.lead.systemPrompt + buildTeamSuffix(config.members),
maxTurns: config.lead.maxTurns ?? 30,
contextStrategy: config.lead.contextStrategy,
abortSignal: parentAbort.signal,
})
const memberAgents = new Map(
config.members.map(m => [
m.id,
{
config: m,
agent: createAgent({
provider: m.provider,
tools: m.tools,
systemPrompt: m.systemPrompt,
maxTurns: m.maxTurns ?? 20,
contextStrategy: m.contextStrategy,
abortSignal: parentAbort.signal,
}),
},
]),
)
async function* run(messages: ModelMessage[]): AsyncGenerator<AgentEvent> {
for await (const event of leadAgent.run(messages)) {
if (event.type === 'tool_call' && event.name === 'delegate') {
const { member, task, context } = event.args as { member: string; task: string; context?: string }
const memberEntry = memberAgents.get(member)
if (!memberEntry) {
leadAgent.resolveToolResult(event.id, {
success: false,
error: `Unknown team member: ${member}`,
})
continue
}
// Yield the delegate tool_call with lead source
yield { ...event, source: 'lead' }
// Signal member start
yield { type: 'member_start', memberId: member, task }
const memberMessages: ModelMessage[] = [
{ role: 'user', content: typeof context === 'string' ? `${task}\n\nContext: ${context}` : task },
]
let memberResult = ''
for await (const memberEvent of memberEntry.agent.run(memberMessages)) {
// Tag all member events with source
yield { ...memberEvent, source: member } as AgentEvent
if (memberEvent.type === 'text') {
memberResult += memberEvent.content
}
}
// Signal member end
yield { type: 'member_end', memberId: member, result: memberResult || 'Task completed.' }
leadAgent.resolveToolResult(event.id, {
success: true,
data: memberResult || 'Task completed.',
})
continue
}
// Tag lead events with source
yield { ...event, source: 'lead' } as AgentEvent
}
}
return {
run,
abort: () => parentAbort.abort(),
resolveToolResult: (id, result) => leadAgent.resolveToolResult(id, result),
}
}
```
- [ ] **Step 4: Run all agent tests**
Run: `cd packages/agent && npx vitest run`
Expected: all 29+ tests PASS
- [ ] **Step 5: Commit**
```bash
git add packages/agent/src/agent-team.ts packages/agent/__tests__/agent-team.test.ts
git commit -m "feat(agent): inject source field and team events in agent-team"
```
---
### Task 4: Extend server agent endpoint for team mode
**Files:**
- Modify: `apps/web/server/api/ai/agent.ts`
- [ ] **Step 1: Add member type and extend AgentBody**
Add `MemberDef` interface and `members` field to `AgentBody` in `apps/web/server/api/ai/agent.ts`:
```typescript
interface MemberDef {
id: string
providerType: 'anthropic' | 'openai-compat'
apiKey: string
model: string
baseURL?: string
systemPrompt?: string
}
interface AgentBody {
// ... existing fields ...
members?: MemberDef[]
}
```
- [ ] **Step 2: Add team creation logic**
Import `createTeam` and `createToolRegistry` at the top (createToolRegistry is already imported). In the "Start agent loop" section, after creating `tools` and `abortController`, add conditional team creation:
```typescript
// After existing tools setup and abortController creation...
let agentOrTeam: { run: (msgs: any) => AsyncGenerator<any>; resolveToolResult: (id: string, result: any) => void }
if (body.members?.length) {
// Team mode — create member agents with their own providers
const members = body.members.map(m => {
const memberProvider = m.providerType === 'anthropic'
? createAnthropicProvider({ apiKey: m.apiKey, model: m.model, baseURL: m.baseURL })
: createOpenAICompatProvider({ apiKey: m.apiKey, model: m.model, baseURL: m.baseURL })
// Members share the same tools as the lead
const memberTools = createToolRegistry()
for (const def of body.toolDefs ?? []) {
const params = def.parameters ? { ...def.parameters } : { type: 'object' }
delete (params as any).$schema
memberTools.register({
name: def.name,
description: def.description,
level: def.level,
schema: jsonSchema(params as any),
})
}
return {
id: m.id,
provider: memberProvider,
tools: memberTools,
systemPrompt: m.systemPrompt || `You are a ${m.id} specialist.`,
}
})
const team = createTeam({
lead: {
provider,
tools,
systemPrompt: body.systemPrompt,
maxTurns: body.maxTurns ?? 20,
contextStrategy: undefined,
},
members,
})
agentOrTeam = {
run: (msgs) => team.run(msgs),
resolveToolResult: (id, result) => team.resolveToolResult(id, result),
}
} else {
// Single agent mode (existing path)
const agent = createAgent({
provider,
tools,
systemPrompt: body.systemPrompt,
maxTurns: body.maxTurns ?? 20,
maxOutputTokens: body.maxOutputTokens,
turnTimeout: 5 * 60_000,
abortSignal: abortController.signal,
})
agentOrTeam = agent
}
```
Update the session registration and SSE loop to use `agentOrTeam` instead of `agent`:
```typescript
agentSessions.set(body.sessionId, { agent: agentOrTeam as any, abortController, createdAt: Date.now(), lastActivity: Date.now() })
```
And in the stream:
```typescript
for await (const agentEvent of agentOrTeam.run(toModelMessages(body.messages))) {
```
- [ ] **Step 3: Add createTeam import**
Add to imports at top of file:
```typescript
import {
createAgent,
createTeam,
createAnthropicProvider,
createOpenAICompatProvider,
createToolRegistry,
encodeAgentEvent,
} from '@zseven-w/agent'
```
- [ ] **Step 4: Type check**
Run: `npx tsc --noEmit --project apps/web/tsconfig.json`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add apps/web/server/api/ai/agent.ts
git commit -m "feat(ai): extend agent endpoint to support team mode with members"
```
---
### Task 5: Add team settings to store
**Files:**
- Modify: `apps/web/src/stores/agent-settings-store.ts`
- [ ] **Step 1: Add team fields to PersistedState and store**
In `PersistedState` interface, add:
```typescript
teamEnabled: boolean
teamDesignModel: string | null
```
In the default state of `useAgentSettingsStore`, add:
```typescript
teamEnabled: false,
teamDesignModel: null,
```
- [ ] **Step 2: Add setter methods to AgentSettingsState interface and implementation**
In the interface:
```typescript
setTeamEnabled: (enabled: boolean) => void
setTeamDesignModel: (model: string | null) => void
```
In the implementation:
```typescript
setTeamEnabled: (teamEnabled) => set({ teamEnabled }),
setTeamDesignModel: (teamDesignModel) => set({ teamDesignModel }),
```
- [ ] **Step 3: Update persist and hydrate**
In `persist()`, add `teamEnabled, teamDesignModel` to the destructured state and JSON.stringify.
In `hydrate()`, add:
```typescript
if ((data as Record<string, unknown>).teamEnabled !== undefined) {
set({ teamEnabled: (data as Record<string, unknown>).teamEnabled as boolean })
}
if ((data as Record<string, unknown>).teamDesignModel !== undefined) {
set({ teamDesignModel: (data as Record<string, unknown>).teamDesignModel as string | null })
}
```
- [ ] **Step 4: Type check**
Run: `npx tsc --noEmit --project apps/web/tsconfig.json`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add apps/web/src/stores/agent-settings-store.ts
git commit -m "feat(ai): add teamEnabled and teamDesignModel to agent settings store"
```
---
### Task 6: Add team section UI in provider settings
**Files:**
- Modify: `apps/web/src/components/shared/builtin-provider-settings.tsx`
- [ ] **Step 1: Create TeamSection component**
Add a new `TeamSection` component at the end of the file (before the final export or after `BuiltinProvidersSection`):
```typescript
/** Team configuration — assign a separate model for design work */
export function TeamSection() {
const { t } = useTranslation()
const builtinProviders = useAgentSettingsStore((s) => s.builtinProviders)
const teamEnabled = useAgentSettingsStore((s) => s.teamEnabled)
const teamDesignModel = useAgentSettingsStore((s) => s.teamDesignModel)
const setTeamEnabled = useAgentSettingsStore((s) => s.setTeamEnabled)
const setTeamDesignModel = useAgentSettingsStore((s) => s.setTeamDesignModel)
const persist = useAgentSettingsStore((s) => s.persist)
// Only show when at least 2 enabled providers exist
const enabledProviders = builtinProviders.filter((p) => p.enabled && p.apiKey)
if (enabledProviders.length < 2) return null
// Build model options from enabled providers
const modelOptions = enabledProviders.map((bp) => ({
value: `builtin:${bp.id}:${bp.model}`,
label: `${bp.model} (${bp.displayName})`,
}))
const handleToggle = (enabled: boolean) => {
setTeamEnabled(enabled)
persist()
}
const handleModelChange = (value: string) => {
setTeamDesignModel(value)
persist()
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t('builtin.teamTitle')}</h3>
<Switch checked={teamEnabled} onCheckedChange={handleToggle} />
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('builtin.teamDescription')}
</p>
{teamEnabled && (
<div>
<label className="text-[11px] text-muted-foreground mb-1 block">{t('builtin.teamDesignModel')}</label>
<select
value={teamDesignModel ?? ''}
onChange={(e) => handleModelChange(e.target.value || null as any)}
className="w-full h-8 px-2 text-[13px] bg-card text-foreground rounded-md border border-input focus:border-ring outline-none transition-colors"
>
<option value="">{t('builtin.teamSelectModel')}</option>
{modelOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)}
</div>
)
}
```
- [ ] **Step 2: Add TeamSection to agent-settings-dialog.tsx**
In `apps/web/src/components/shared/agent-settings-dialog.tsx`, import and render `TeamSection` in the Agents tab, between the `BuiltinProvidersSection` and the agents list. Find where `<BuiltinProvidersSection />` is rendered and add `<TeamSection />` after it with a divider:
```typescript
import { BuiltinProvidersSection, TeamSection } from './builtin-provider-settings'
```
```tsx
<BuiltinProvidersSection />
<TeamSection />
```
- [ ] **Step 3: Type check**
Run: `npx tsc --noEmit --project apps/web/tsconfig.json`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add apps/web/src/components/shared/builtin-provider-settings.tsx apps/web/src/components/shared/agent-settings-dialog.tsx
git commit -m "feat(ai): add Team section UI with design model selector"
```
---
### Task 7: Add i18n keys for team UI
**Files:**
- Modify: all 15 files in `apps/web/src/i18n/locales/`
- [ ] **Step 1: Add English keys**
In `apps/web/src/i18n/locales/en.ts`, append after the last `builtin.*` key (before `// ── Figma Import ──`):
```typescript
'builtin.teamTitle': 'Team',
'builtin.teamDescription': 'Use different models for chat and design. The chat model handles conversation; the design model handles generate_design.',
'builtin.teamDesignModel': 'Design Model',
'builtin.teamSelectModel': 'Select a model...',
```
- [ ] **Step 2: Add translations for all other 14 locales**
Add the same 4 keys to each locale file with appropriate translations. Keep "generate_design" untranslated (it's a tool name). Insert after the last `builtin.*` key in each file.
zh.ts:
```typescript
'builtin.teamTitle': '团队',
'builtin.teamDescription': '为对话和设计使用不同模型。对话模型处理聊天,设计模型处理 generate_design。',
'builtin.teamDesignModel': '设计模型',
'builtin.teamSelectModel': '选择模型...',
```
zh-tw.ts:
```typescript
'builtin.teamTitle': '團隊',
'builtin.teamDescription': '為對話和設計使用不同模型。對話模型處理聊天,設計模型處理 generate_design。',
'builtin.teamDesignModel': '設計模型',
'builtin.teamSelectModel': '選擇模型...',
```
ja.ts:
```typescript
'builtin.teamTitle': 'チーム',
'builtin.teamDescription': 'チャットとデザインに異なるモデルを使用します。チャットモデルは会話を、デザインモデルは generate_design を処理します。',
'builtin.teamDesignModel': 'デザインモデル',
'builtin.teamSelectModel': 'モデルを選択...',
```
ko.ts:
```typescript
'builtin.teamTitle': '팀',
'builtin.teamDescription': '대화와 디자인에 다른 모델을 사용합니다. 대화 모델은 채팅을, 디자인 모델은 generate_design을 처리합니다.',
'builtin.teamDesignModel': '디자인 모델',
'builtin.teamSelectModel': '모델 선택...',
```
fr.ts:
```typescript
'builtin.teamTitle': 'Équipe',
'builtin.teamDescription': 'Utilisez différents modèles pour le chat et le design. Le modèle de chat gère la conversation ; le modèle de design gère generate_design.',
'builtin.teamDesignModel': 'Modèle de design',
'builtin.teamSelectModel': 'Sélectionner un modèle...',
```
es.ts:
```typescript
'builtin.teamTitle': 'Equipo',
'builtin.teamDescription': 'Usa modelos diferentes para chat y diseño. El modelo de chat maneja la conversación; el modelo de diseño maneja generate_design.',
'builtin.teamDesignModel': 'Modelo de diseño',
'builtin.teamSelectModel': 'Seleccionar modelo...',
```
de.ts:
```typescript
'builtin.teamTitle': 'Team',
'builtin.teamDescription': 'Verwenden Sie verschiedene Modelle für Chat und Design. Das Chat-Modell übernimmt die Konversation, das Design-Modell übernimmt generate_design.',
'builtin.teamDesignModel': 'Design-Modell',
'builtin.teamSelectModel': 'Modell auswählen...',
```
pt.ts:
```typescript
'builtin.teamTitle': 'Equipe',
'builtin.teamDescription': 'Use modelos diferentes para chat e design. O modelo de chat gerencia a conversa; o modelo de design gerencia generate_design.',
'builtin.teamDesignModel': 'Modelo de design',
'builtin.teamSelectModel': 'Selecionar modelo...',
```
ru.ts:
```typescript
'builtin.teamTitle': 'Команда',
'builtin.teamDescription': 'Используйте разные модели для чата и дизайна. Модель чата обрабатывает беседу, модель дизайна обрабатывает generate_design.',
'builtin.teamDesignModel': 'Модель дизайна',
'builtin.teamSelectModel': 'Выберите модель...',
```
hi.ts:
```typescript
'builtin.teamTitle': 'टीम',
'builtin.teamDescription': 'चैट और डिज़ाइन के लिए अलग-अलग मॉडल का उपयोग करें। चैट मॉडल बातचीत संभालता है; डिज़ाइन मॉडल generate_design संभालता है।',
'builtin.teamDesignModel': 'डिज़ाइन मॉडल',
'builtin.teamSelectModel': 'मॉडल चुनें...',
```
tr.ts:
```typescript
'builtin.teamTitle': 'Takım',
'builtin.teamDescription': 'Sohbet ve tasarım için farklı modeller kullanın. Sohbet modeli konuşmayı, tasarım modeli generate_design\'ı yönetir.',
'builtin.teamDesignModel': 'Tasarım Modeli',
'builtin.teamSelectModel': 'Model seçin...',
```
th.ts:
```typescript
'builtin.teamTitle': 'ทีม',
'builtin.teamDescription': 'ใช้โมเดลที่แตกต่างกันสำหรับแชทและการออกแบบ โมเดลแชทจัดการการสนทนา โมเดลออกแบบจัดการ generate_design',
'builtin.teamDesignModel': 'โมเดลออกแบบ',
'builtin.teamSelectModel': 'เลือกโมเดล...',
```
vi.ts:
```typescript
'builtin.teamTitle': 'Nhóm',
'builtin.teamDescription': 'Sử dụng các mô hình khác nhau cho trò chuyện và thiết kế. Mô hình trò chuyện xử lý hội thoại; mô hình thiết kế xử lý generate_design.',
'builtin.teamDesignModel': 'Mô hình thiết kế',
'builtin.teamSelectModel': 'Chọn mô hình...',
```
id.ts:
```typescript
'builtin.teamTitle': 'Tim',
'builtin.teamDescription': 'Gunakan model berbeda untuk obrolan dan desain. Model obrolan menangani percakapan; model desain menangani generate_design.',
'builtin.teamDesignModel': 'Model Desain',
'builtin.teamSelectModel': 'Pilih model...',
```
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/i18n/locales/
git commit -m "feat(ai): add i18n keys for team settings (15 locales)"
```
---
### Task 8: Wire team mode in chat handlers
**Files:**
- Modify: `apps/web/src/components/panels/ai-chat-handlers.ts`
- Modify: `apps/web/src/services/ai/ai-types.ts`
- [ ] **Step 1: Add source to ChatMessage**
In `apps/web/src/services/ai/ai-types.ts`, add `source` to `ChatMessage`:
```typescript
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
isStreaming?: boolean
attachments?: ChatAttachment[]
source?: string
}
```
- [ ] **Step 2: Construct team members in agent request**
In `apps/web/src/components/panels/ai-chat-handlers.ts`, find where the agent body is constructed for the POST to `/api/ai/agent`. After the existing body construction, add team member injection:
```typescript
// After body is constructed, before fetch:
const { teamEnabled, teamDesignModel, builtinProviders: allBps } = useAgentSettingsStore.getState()
if (teamEnabled && teamDesignModel) {
const designParts = teamDesignModel.split(':')
const designBpId = designParts[1]
const designModelName = designParts.slice(2).join(':')
const designBp = allBps.find((p) => p.id === designBpId)
if (designBp?.apiKey) {
;(body as any).members = [{
id: 'designer',
providerType: designBp.type === 'anthropic' ? 'anthropic' : 'openai-compat',
apiKey: designBp.apiKey,
model: designModelName,
baseURL: designBp.baseURL,
systemPrompt: 'You are a design specialist. Use the generate_design tool to create designs based on the task description. Focus on high-quality visual output.',
}]
}
}
```
- [ ] **Step 3: Handle member_start and member_end events**
In the SSE event switch statement (inside `runAgentStream`), add cases for new event types:
```typescript
case 'member_start': {
const status = `**${evt.memberId}** working: ${evt.task}`
accumulated += `\n\n---\n${status}\n`
updateLastMessage(accumulated)
break
}
case 'member_end': {
accumulated += `\n---\n`
updateLastMessage(accumulated)
break
}
```
- [ ] **Step 4: Type check**
Run: `npx tsc --noEmit --project apps/web/tsconfig.json`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add apps/web/src/services/ai/ai-types.ts apps/web/src/components/panels/ai-chat-handlers.ts
git commit -m "feat(ai): wire team mode in chat handlers with member events"
```
---
### Task 9: End-to-end verification
- [ ] **Step 1: Run all agent SDK tests**
Run: `cd packages/agent && npx vitest run`
Expected: all tests pass
- [ ] **Step 2: Run type check**
Run: `npx tsc --noEmit --project apps/web/tsconfig.json`
Expected: no errors
- [ ] **Step 3: Manual verification**
1. Open editor at `http://localhost:3000/editor`
2. Agents & MCP → verify Team section appears when 2+ providers are enabled
3. Enable Team toggle → select a design model
4. Send a design request → verify member_start/member_end events appear in chat
5. Disable Team toggle → verify single agent mode works as before
- [ ] **Step 4: Final commit if any fixes needed**
```bash
git add -A
git commit -m "fix(ai): address team integration issues found during verification"
```

View file

@ -0,0 +1,244 @@
# Agent Team Web Integration
**Date:** 2026-03-28
**Status:** Approved
**Scope:** Phase 3 of builtin agent design — integrate Agent Team into the web app
## Goal
Enable model routing via Agent Team: a fast/cheap model handles conversation (lead), a capable model handles design generation (member). Users assign models to roles in settings; the system automatically constructs a team when both roles are configured.
## Non-Goals
- Custom team builder UI (arbitrary roles, system prompts, tool configs)
- More than two roles (lead + designer)
- Nested agent block UI (member events displayed inline)
- Agent Team in CLI or desktop-specific code
## Architecture
### SDK Layer (`packages/agent/`)
All generic team functionality lives in the SDK. No OpenPencil domain knowledge.
#### 1. AgentEvent Source Tracking
Add optional `source` field to all event types plus two new team-specific events.
**File: `streaming/types.ts`**
```typescript
// Add source to existing event variants (optional, undefined for single-agent mode)
| { type: 'text'; content: string; source?: string }
| { type: 'thinking'; content: string; source?: string }
| { type: 'tool_call'; id: string; name: string; args: unknown; level: AuthLevel; source?: string }
| { type: 'tool_result'; id: string; name: string; result: ToolResult; source?: string }
| { type: 'turn'; turn: number; maxTurns: number; source?: string }
| { type: 'error'; message: string; fatal: boolean; source?: string }
// New team events
| { type: 'member_start'; memberId: string; task: string }
| { type: 'member_end'; memberId: string; result: string }
```
Backward compatible: `source` is optional, absent in single-agent mode.
#### 2. Team Event Injection
**File: `agent-team.ts`**
Wrap lead events with `source: 'lead'`, member events with `source: memberId`. Yield `member_start` before member execution and `member_end` after.
```typescript
// Lead events
for await (const event of leadAgent.run(messages)) {
if (event.type === 'tool_call' && event.name === 'delegate') {
yield { type: 'member_start', memberId: member, task }
for await (const memberEvent of memberEntry.agent.run(memberMessages)) {
yield { ...memberEvent, source: member }
}
yield { type: 'member_end', memberId: member, result: memberResult }
} else {
yield { ...event, source: 'lead' }
}
}
```
#### 3. Auto-inject Team Suffix into Lead System Prompt
**File: `agent-team.ts`**
Append member descriptions to the lead's system prompt so the LLM knows it has team members:
```typescript
const teamSuffix = `
## Team Members
You have team members available via the \`delegate\` tool:
${config.members.map(m => `- **${m.id}**: ${m.systemPrompt?.split('\n')[0] || 'Available for sub-tasks'}`).join('\n')}
When the user's request involves design generation, delegate to the appropriate member.
Handle conversation, planning, and simple queries yourself.`
```
This is domain-agnostic — it just lists members and their first-line descriptions.
#### 4. SSE Encoder/Decoder
**Files: `streaming/sse-encoder.ts`, `streaming/sse-decoder.ts`**
Extend to handle `source`, `member_start`, `member_end`. The encoder serializes them as JSON in the SSE data field. The decoder parses them back. No format changes needed — these are just new fields/types in the existing JSON payload.
### Server Layer (`apps/web/server/`)
#### 5. Extend Agent Endpoint
**File: `server/api/ai/agent.ts`**
Expand `AgentBody` with optional `members` array:
```typescript
interface AgentBody {
// ... existing fields unchanged ...
members?: Array<{
id: string
providerType: 'anthropic' | 'openai-compat'
apiKey: string
model: string
baseURL?: string
systemPrompt?: string
}>
}
```
Routing logic:
- No `members` or empty → `createAgent()` (existing path, fully backward compatible)
- `members` present → `createTeam()` with lead from main body fields, each member gets its own provider
`resolveToolResult` routing: `AgentTeam` maintains a `toolCallOwners` map that tracks which agent (lead or member) issued each tool call. When the server receives a tool result, `team.resolveToolResult()` looks up the owner and routes to the correct agent. This is critical — member tool calls (e.g. `generate_design`) must be routed to the member agent, not the lead.
Member tools: Members receive the **same tool registry** as the lead (server creates a copy from `body.toolDefs` for each member). This ensures the designer member can call `generate_design` and other design tools.
SSE stream code unchanged — `encodeAgentEvent` handles new event types automatically.
Source and ChatMessage: The `source` field on `ChatMessage` represents the overall message origin. Within a single assistant message, lead and member text are visually separated via markdown dividers (`---`) inserted by `member_start`/`member_end` event handlers. Per-segment source tracking is not needed for the MVP.
### Client Layer (`apps/web/`)
#### 6. Settings Store
**File: `stores/agent-settings-store.ts`**
Add two persisted fields:
```typescript
teamEnabled: boolean // default: false
teamDesignModel: string | null // format: 'builtin:{id}:{model}', default: null
```
Methods: `setTeamEnabled(enabled)`, `setTeamDesignModel(model)`.
#### 7. Team Configuration UI
**File: `components/shared/builtin-provider-settings.tsx`**
Add a "Team" section below the provider list:
- **Enable toggle**: switches team mode on/off
- **Design Model dropdown**: selects from configured builtin providers (same model selector format as chat)
- Info text explaining: "Chat model handles conversation. Design model handles generate_design."
- Only shown when at least 2 builtin providers are configured and enabled
All text uses i18n keys under `builtin.team*` namespace.
#### 8. Chat Handler — Team Request Construction
**File: `components/panels/ai-chat-handlers.ts`**
When `teamEnabled && teamDesignModel`:
```typescript
const designParts = teamDesignModel.split(':')
const designBp = builtinProviders.find(p => p.id === designParts[1])
body.members = [{
id: 'designer',
providerType: designBp.type === 'anthropic' ? 'anthropic' : 'openai-compat',
apiKey: designBp.apiKey,
model: designParts.slice(2).join(':'),
baseURL: designBp.baseURL,
systemPrompt: 'You are a design specialist. Use the generate_design tool to create designs based on the task description. Focus on high-quality visual output.',
}]
```
#### 9. Chat Handler — New Event Types
**File: `components/panels/ai-chat-handlers.ts`**
```typescript
case 'member_start':
// Add inline status in chat: "🎨 Designer working on: {task}"
break
case 'member_end':
// Update status to complete
break
```
For events with `source`, tag the message with source info. Only display source labels in team mode (when `source` is present).
#### 10. AI Store
**File: `stores/ai-store.ts`**
Add optional `source?: string` to `ChatMessage` interface. Used for display only — distinguishes lead vs member text in the chat.
## Data Flow
```
User → "设计一个电商首页"
[Client] teamEnabled=true → body.members=[{designer}]
[Server] members present → createTeam(lead=chatModel, members=[designerModel])
[SDK] lead → delegate({member:'designer', task:'...'})
yield member_start
designer → generate_design tool call
yield tool_call (source:'designer')
client executes tool, posts result
designer → text output (source:'designer')
yield member_end
lead → summary (source:'lead')
yield done
[Client] renders inline: "Designer working..." → design appears → lead summary
```
## Backward Compatibility
- `source` is optional on all events — single agent mode unchanged
- No `members` in body → existing `createAgent` path, zero impact
- `teamEnabled` defaults to false — new users see no change
- All new UI behind the team toggle
## File Change Summary
| Layer | File | Change |
|-------|------|--------|
| SDK | `streaming/types.ts` | `source` field + 2 new event types |
| SDK | `agent-team.ts` | Inject source, yield member_start/end, team suffix |
| SDK | `streaming/sse-encoder.ts` | Encode new fields |
| SDK | `streaming/sse-decoder.ts` | Decode new fields |
| SDK | Tests | Update existing team tests, add source/event tests |
| Server | `agent.ts` | Accept members, conditionally use createTeam |
| Client | `agent-settings-store.ts` | teamEnabled + teamDesignModel |
| Client | `builtin-provider-settings.tsx` | Team section UI |
| Client | `ai-chat-handlers.ts` | Construct members, handle new events |
| Client | `ai-store.ts` | source field on ChatMessage |
| i18n | All 15 locales | builtin.team* keys |

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.5.2",
"version": "0.6.0",
"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": {
"name": "ZSeven-W",

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest'
import { z } from 'zod'
import { createAgent } from '../src/agent-loop'
import { createToolRegistry } from '../src/tools/tool-registry'
import type { AgentProvider } from '../src/providers/types'
import type { AgentEvent } from '../src/streaming/types'
const mockProvider: AgentProvider = {
id: 'mock',
maxContextTokens: 100_000,
supportsThinking: false,
model: {} as any,
}
describe('createAgent', () => {
it('creates an agent with required config', () => {
const tools = createToolRegistry()
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
maxTurns: 5,
})
expect(agent).toHaveProperty('run')
expect(agent).toHaveProperty('resolveToolResult')
})
it('agent.run returns an async iterable', async () => {
const tools = createToolRegistry()
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
maxTurns: 5,
})
const stream = agent.run([{ role: 'user', content: 'hi' }])
expect(stream[Symbol.asyncIterator]).toBeDefined()
})
it('resolveToolResult throws for unknown tool call id', () => {
const tools = createToolRegistry()
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
})
expect(() => agent.resolveToolResult('nonexistent', { success: true }))
.toThrow('No pending tool call: nonexistent')
})
it('yields abort event when signal is already aborted', async () => {
const tools = createToolRegistry()
const controller = new AbortController()
controller.abort()
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
abortSignal: controller.signal,
})
const events: AgentEvent[] = []
for await (const event of agent.run([{ role: 'user', content: 'hi' }])) {
events.push(event)
}
// Abort check runs before the turn yield, so only abort is emitted
expect(events).toHaveLength(1)
expect(events[0]).toEqual({ type: 'abort' })
})
it('uses default maxTurns of 20', () => {
const tools = createToolRegistry()
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
})
// Verify it was created without error — default maxTurns=20 is internal
expect(agent).toBeDefined()
})
})

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { createTeam } from '../src/agent-team'
import { createToolRegistry } from '../src/tools/tool-registry'
import type { AgentProvider } from '../src/providers/types'
const mockProvider: AgentProvider = {
id: 'mock',
maxContextTokens: 100_000,
supportsThinking: false,
model: {} as any,
}
describe('createTeam', () => {
it('creates a team with lead and members', () => {
const team = createTeam({
lead: {
provider: mockProvider,
tools: createToolRegistry(),
systemPrompt: 'You are a lead.',
},
members: [
{ id: 'worker', provider: mockProvider, tools: createToolRegistry(), systemPrompt: 'You are a worker.' },
],
})
expect(team).toHaveProperty('run')
expect(team).toHaveProperty('abort')
})
it('accepts different providers for lead and members', () => {
const otherProvider: AgentProvider = { ...mockProvider, id: 'other' }
const team = createTeam({
lead: { provider: mockProvider, tools: createToolRegistry(), systemPrompt: 'Lead' },
members: [
{ id: 'member1', provider: otherProvider, tools: createToolRegistry(), systemPrompt: 'Member' },
],
})
expect(team).toBeDefined()
})
it('does not mutate the caller tools registry', () => {
const tools = createToolRegistry()
const initialCount = tools.list().length
createTeam({
lead: { provider: mockProvider, tools, systemPrompt: 'Lead' },
members: [{ id: 'worker', provider: mockProvider, tools: createToolRegistry(), systemPrompt: 'Worker' }],
})
expect(tools.list().length).toBe(initialCount)
})
})

View file

@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import { createSlidingWindowStrategy } from '../../src/context/sliding-window'
describe('createSlidingWindowStrategy', () => {
const strategy = createSlidingWindowStrategy({ maxTurns: 3 })
it('keeps messages within maxTurns', () => {
const messages = Array.from({ length: 10 }, (_, i) => ({
role: (i % 2 === 0 ? 'user' : 'assistant') as const,
content: `Message ${i}`,
}))
const trimmed = strategy.trim(messages, 100_000)
expect(trimmed.length).toBeLessThanOrEqual(6) // 3 turns = 6 messages
})
it('preserves system messages', () => {
const messages = [
{ role: 'system' as const, content: 'You are helpful' },
{ role: 'user' as const, content: 'old message' },
{ role: 'assistant' as const, content: 'old reply' },
{ role: 'user' as const, content: 'new message' },
{ role: 'assistant' as const, content: 'new reply' },
]
const strategy1 = createSlidingWindowStrategy({ maxTurns: 1 })
const trimmed = strategy1.trim(messages, 100_000)
expect(trimmed[0]).toEqual({ role: 'system', content: 'You are helpful' })
// 1 logical turn = last user or assistant message group
// The last turn starts at "new message" (user) → keeps user + assistant = 2 + system = 3
// OR starts at "new reply" (assistant) → keeps just 1 + system = 2
// Our impl: turnStarts = [user@0, assistant@1, user@2, assistant@3] → keep last 1 → from index 3 → assistant only
// So: system + assistant("new reply") = 2
expect(trimmed.length).toBeLessThanOrEqual(3)
})
it('returns all messages if within limits', () => {
const messages = [
{ role: 'user' as const, content: 'hi' },
{ role: 'assistant' as const, content: 'hello' },
]
const trimmed = strategy.trim(messages, 100_000)
expect(trimmed).toEqual(messages)
})
it('never splits assistant+tool groups', () => {
// Simulate: user, assistant(tool-call), tool, tool, assistant(text), user, assistant(tool-call), tool
const messages = [
{ role: 'user' as const, content: 'first request' },
{ role: 'assistant' as const, content: 'calling tools' },
{ role: 'tool' as const, content: [{ type: 'tool-result' as const, toolCallId: 't1', toolName: 'foo', output: { type: 'text' as const, value: 'r1' } }] },
{ role: 'tool' as const, content: [{ type: 'tool-result' as const, toolCallId: 't2', toolName: 'bar', output: { type: 'text' as const, value: 'r2' } }] },
{ role: 'assistant' as const, content: 'done with first' },
{ role: 'user' as const, content: 'second request' },
{ role: 'assistant' as const, content: 'calling more tools' },
{ role: 'tool' as const, content: [{ type: 'tool-result' as const, toolCallId: 't3', toolName: 'baz', output: { type: 'text' as const, value: 'r3' } }] },
]
const strategy2 = createSlidingWindowStrategy({ maxTurns: 2 })
const trimmed = strategy2.trim(messages as any, 100_000)
// First message should be user or assistant, NEVER tool
expect(trimmed[0].role).not.toBe('tool')
// Should keep last 2 logical turns
expect(trimmed.length).toBeGreaterThanOrEqual(3)
})
it('first kept message is never a tool message', () => {
const messages = [
{ role: 'user' as const, content: 'q1' },
{ role: 'assistant' as const, content: 'a1' },
{ role: 'tool' as const, content: [{ type: 'tool-result' as const, toolCallId: 't1', toolName: 'x', output: { type: 'text' as const, value: '' } }] },
{ role: 'user' as const, content: 'q2' },
{ role: 'assistant' as const, content: 'a2' },
{ role: 'tool' as const, content: [{ type: 'tool-result' as const, toolCallId: 't2', toolName: 'y', output: { type: 'text' as const, value: '' } }] },
{ role: 'user' as const, content: 'q3' },
{ role: 'assistant' as const, content: 'a3' },
]
const strategy1 = createSlidingWindowStrategy({ maxTurns: 1 })
const trimmed = strategy1.trim(messages as any, 100_000)
// Should never start with tool
expect(trimmed[0].role).not.toBe('tool')
})
})

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { createDelegateTool } from '../src/tools/delegate'
describe('createDelegateTool', () => {
it('creates a tool with orchestrate level', () => {
const tool = createDelegateTool(['designer', 'reviewer'])
expect(tool.name).toBe('delegate')
expect(tool.level).toBe('orchestrate')
})
it('schema validates member names', () => {
const tool = createDelegateTool(['designer', 'reviewer'])
const result = tool.schema.safeParse({ member: 'designer', task: 'do thing' })
expect(result.success).toBe(true)
})
it('schema rejects unknown members', () => {
const tool = createDelegateTool(['designer', 'reviewer'])
const result = tool.schema.safeParse({ member: 'unknown', task: 'do thing' })
expect(result.success).toBe(false)
})
})

View file

@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { createAnthropicProvider } from '../../src/providers/anthropic'
describe('createAnthropicProvider', () => {
it('creates a provider with correct metadata', () => {
const provider = createAnthropicProvider({
apiKey: 'test-key',
model: 'claude-sonnet-4-20250514',
})
expect(provider.id).toBe('anthropic')
expect(provider.supportsThinking).toBe(true)
expect(provider.maxContextTokens).toBe(200_000)
expect(provider.model).toBeDefined()
})
it('allows custom maxContextTokens', () => {
const provider = createAnthropicProvider({
apiKey: 'test-key',
model: 'claude-sonnet-4-20250514',
maxContextTokens: 100_000,
})
expect(provider.maxContextTokens).toBe(100_000)
})
})

View file

@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { createOpenAICompatProvider } from '../../src/providers/openai-compat'
describe('createOpenAICompatProvider', () => {
it('creates a provider with correct metadata', () => {
const provider = createOpenAICompatProvider({
apiKey: 'test-key',
model: 'gpt-4o',
})
expect(provider.id).toBe('openai-compat')
expect(provider.supportsThinking).toBe(false)
expect(provider.maxContextTokens).toBe(128_000)
expect(provider.model).toBeDefined()
})
it('supports custom baseURL', () => {
const provider = createOpenAICompatProvider({
apiKey: 'test-key',
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com/v1',
})
expect(provider.id).toBe('openai-compat')
})
})

View file

@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { decodeAgentEvent } from '../../src/streaming/sse-decoder'
describe('decodeAgentEvent', () => {
it('decodes a text event', () => {
const raw = 'event: text\ndata: {"content":"hello"}\n\n'
const event = decodeAgentEvent(raw)
expect(event).toEqual({ type: 'text', content: 'hello' })
})
it('decodes a tool_call event', () => {
const raw = 'event: tool_call\ndata: {"id":"tc_1","name":"read_file","args":{"path":"/foo"},"level":"read"}\n\n'
const event = decodeAgentEvent(raw)
expect(event).toEqual({
type: 'tool_call', id: 'tc_1', name: 'read_file',
args: { path: '/foo' }, level: 'read',
})
})
it('returns null for malformed input', () => {
expect(decodeAgentEvent('garbage')).toBeNull()
expect(decodeAgentEvent('')).toBeNull()
})
it('decodes a text event with source', () => {
const raw = 'event: text\ndata: {"content":"hello","source":"designer"}'
const event = decodeAgentEvent(raw)
expect(event).toEqual({ type: 'text', content: 'hello', source: 'designer' })
})
it('decodes a member_start event', () => {
const raw = 'event: member_start\ndata: {"memberId":"designer","task":"Build UI"}'
const event = decodeAgentEvent(raw)
expect(event).toEqual({ type: 'member_start', memberId: 'designer', task: 'Build UI' })
})
it('decodes a member_end event', () => {
const raw = 'event: member_end\ndata: {"memberId":"designer","result":"Done"}'
const event = decodeAgentEvent(raw)
expect(event).toEqual({ type: 'member_end', memberId: 'designer', result: 'Done' })
})
})

View file

@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { encodeAgentEvent } from '../../src/streaming/sse-encoder'
import type { AgentEvent } from '../../src/streaming/types'
describe('encodeAgentEvent', () => {
it('encodes a text event', () => {
const event: AgentEvent = { type: 'text', content: 'hello' }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: text\ndata: {"content":"hello"}\n\n')
})
it('encodes a tool_call event', () => {
const event: AgentEvent = {
type: 'tool_call', id: 'tc_1', name: 'read_file',
args: { path: '/foo' }, level: 'read',
}
const encoded = encodeAgentEvent(event)
expect(encoded).toContain('event: tool_call')
expect(encoded).toContain('"id":"tc_1"')
})
it('encodes a done event', () => {
const event: AgentEvent = { type: 'done', totalTurns: 3 }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: done\ndata: {"totalTurns":3}\n\n')
})
it('encodes a text event with source', () => {
const event: AgentEvent = { type: 'text', content: 'hello', source: 'lead' }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: text\ndata: {"content":"hello","source":"lead"}\n\n')
})
it('encodes a member_start event', () => {
const event: AgentEvent = { type: 'member_start', memberId: 'designer', task: 'Create landing page' }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: member_start\ndata: {"memberId":"designer","task":"Create landing page"}\n\n')
})
it('encodes a member_end event', () => {
const event: AgentEvent = { type: 'member_end', memberId: 'designer', result: 'Done' }
const encoded = encodeAgentEvent(event)
expect(encoded).toBe('event: member_end\ndata: {"memberId":"designer","result":"Done"}\n\n')
})
})

View file

@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { createToolRegistry } from '../src/tools/tool-registry'
describe('createToolRegistry', () => {
it('registers and retrieves a tool', () => {
const registry = createToolRegistry()
registry.register({
name: 'read_file',
description: 'Read a file',
schema: z.object({ path: z.string() }),
level: 'read',
})
expect(registry.get('read_file')).toBeDefined()
expect(registry.get('read_file')!.level).toBe('read')
})
it('lists all registered tools', () => {
const registry = createToolRegistry()
registry.register({ name: 'tool_a', description: 'A', schema: z.object({}), level: 'read' })
registry.register({ name: 'tool_b', description: 'B', schema: z.object({}), level: 'modify' })
expect(registry.list()).toHaveLength(2)
})
it('converts to Vercel AI SDK format', () => {
const registry = createToolRegistry()
registry.register({
name: 'insert_node',
description: 'Insert a node',
schema: z.object({ parent: z.string(), data: z.object({}).passthrough() }),
level: 'create',
})
const sdkTools = registry.toAISDKFormat()
expect(sdkTools).toHaveProperty('insert_node')
expect(sdkTools.insert_node).toHaveProperty('description', 'Insert a node')
expect(sdkTools.insert_node).toHaveProperty('inputSchema')
})
it('throws on duplicate registration', () => {
const registry = createToolRegistry()
const tool = { name: 'dup', description: 'D', schema: z.object({}), level: 'read' as const }
registry.register(tool)
expect(() => registry.register(tool)).toThrow()
})
})

View file

@ -0,0 +1,23 @@
{
"name": "@zseven-w/agent",
"version": "0.6.0",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"files": [
"src"
],
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"ai": "^6.0",
"@ai-sdk/anthropic": "^3.0",
"@ai-sdk/openai": "^3.0",
"zod": "^3.24"
}
}

View file

@ -0,0 +1,369 @@
import { streamText } from 'ai'
import type { ModelMessage } from 'ai'
import type { AgentProvider } from './providers/types'
import type { ToolRegistry } from './tools/tool-registry'
import type { ToolResult } from './tools/types'
import type { AgentEvent } from './streaming/types'
import type { ContextStrategy } from './context/types'
import { createSlidingWindowStrategy } from './context/sliding-window'
/**
* Appended to system prompt when tools are disabled (model doesn't support function calling).
* Tells the model to output design JSON in a code block same approach as the CLI pipeline.
* The client parses the JSON and inserts nodes.
*/
const TEXT_MODE_SUFFIX = `
## IMPORTANT: Text-Only Mode
Function calling is not available. Instead, output your design as a JSON code block.
Wrap the root node JSON in a \`\`\`json code fence. Example:
\`\`\`json
{
"type": "frame", "name": "Login Screen", "x": 0, "y": 0, "width": 390, "height": 844,
"fills": [{"type": "solid", "color": "#FFFFFF"}], "cornerRadius": 40,
"layout": "vertical", "padding": [60, 24, 40, 24], "gap": 16, "alignItems": "stretch",
"children": [
{"type": "text", "text": "Welcome", "fontSize": 28, "fontWeight": 700, "fills": [{"type": "solid", "color": "#1a1a2e"}]},
{"type": "frame", "name": "Button", "height": 48, "fills": [{"type": "solid", "color": "#4F46E5"}], "cornerRadius": 12, "justifyContent": "center", "alignItems": "center", "children": [
{"type": "text", "text": "Sign In", "fontSize": 16, "fontWeight": 600, "fills": [{"type": "solid", "color": "#FFFFFF"}]}
]}
]
}
\`\`\`
Output ONE JSON code block with the complete design tree. Do NOT use function calls.`
export interface AgentConfig {
provider: AgentProvider
tools: ToolRegistry
systemPrompt: string
maxTurns?: number
maxOutputTokens?: number
turnTimeout?: number
contextStrategy?: ContextStrategy
abortSignal?: AbortSignal
}
export interface Agent {
run(messages: ModelMessage[]): AsyncGenerator<AgentEvent>
resolveToolResult(toolCallId: string, result: ToolResult): void
}
/** Classify API errors into actionable categories for the user. */
function classifyAPIError(err: any): { userMessage: string; retryWithoutTools: boolean } {
const msg = err?.message ?? String(err)
const body = err?.responseBody ?? ''
const status = err?.statusCode ?? err?.status
// Match on HTTP status codes first (more reliable than regex)
if (status === 429) {
return {
userMessage: 'Rate limited by the API provider. Please wait a moment and try again.',
retryWithoutTools: false,
}
}
if (status === 402 || status === 403) {
if (/credit|funds|billing|afford/i.test(msg + body)) {
return {
userMessage: 'Insufficient credits for this model. Try a smaller/free model or add credits at your provider.',
retryWithoutTools: false,
}
}
}
// Model doesn't support function calling properly
if (
/tool_calls.*required|function.*arguments.*required|invalid.*function.*arguments|does not support.*tool/i.test(msg + body)
) {
return {
userMessage: 'This model does not support function calling properly. The agent will try without tools.',
retryWithoutTools: true,
}
}
// OpenRouter privacy/guardrail restrictions
if (/No endpoints available.*guardrail|data policy/i.test(msg + body)) {
return {
userMessage: 'This model is blocked by your OpenRouter privacy settings. Visit https://openrouter.ai/settings/privacy to configure, or switch to a different model.',
retryWithoutTools: false,
}
}
// Credit/billing issues
if (/more credits|can only afford|insufficient.*funds|billing/i.test(msg + body)) {
return {
userMessage: 'Insufficient credits for this model. Try a smaller/free model or add credits at your provider.',
retryWithoutTools: false,
}
}
// Rate limiting
if (/rate.limit|too many requests|429/i.test(msg + body)) {
return {
userMessage: 'Rate limited by the API provider. Please wait a moment and try again.',
retryWithoutTools: false,
}
}
// Generic 400 — often means the model can't handle our request format
if (err?.statusCode === 400 && /provider returned error/i.test(msg + body)) {
return {
userMessage: 'The model returned an error. It may not support tool calling. Retrying without tools.',
retryWithoutTools: true,
}
}
// Fallback
return { userMessage: msg, retryWithoutTools: false }
}
export function createAgent(config: AgentConfig): Agent {
const {
provider,
tools,
systemPrompt,
maxTurns = 20,
maxOutputTokens,
turnTimeout = 60_000,
contextStrategy = createSlidingWindowStrategy({ maxTurns: 50 }),
abortSignal,
} = config
// Pending tool call resolution map — for tools without execute()
const pending = new Map<string, {
resolve: (result: ToolResult) => void
reject: (error: Error) => void
}>()
function resolveToolResult(toolCallId: string, result: ToolResult): void {
const entry = pending.get(toolCallId)
if (!entry) throw new Error(`No pending tool call: ${toolCallId}`)
pending.delete(toolCallId)
entry.resolve(result)
}
function waitForToolResult(toolCallId: string): Promise<ToolResult> {
return new Promise<ToolResult>((resolve, reject) => {
const timeout = setTimeout(
() => {
pending.delete(toolCallId)
reject(new Error(`Tool call ${toolCallId} timed out after ${turnTimeout}ms`))
},
turnTimeout,
)
pending.set(toolCallId, {
resolve: (result) => { clearTimeout(timeout); resolve(result) },
reject: (error) => { clearTimeout(timeout); reject(error) },
})
})
}
async function* run(messages: ModelMessage[]): AsyncGenerator<AgentEvent> {
let turn = 0
const history = [...messages]
let toolsDisabled = false
try {
while (turn < maxTurns) {
if (abortSignal?.aborted) {
yield { type: 'abort' }
return
}
yield { type: 'turn', turn, maxTurns }
// Apply context strategy before each LLM call
const trimmedMessages = contextStrategy.trim(
history,
provider.maxContextTokens,
)
// When tools are disabled (model doesn't support function calling),
// switch to text-based JSON output — same approach as the CLI pipeline.
const effectiveSystem = toolsDisabled
? systemPrompt + TEXT_MODE_SUFFIX
: systemPrompt
let response: ReturnType<typeof streamText>
try {
response = streamText({
model: provider.model,
system: effectiveSystem,
messages: trimmedMessages as ModelMessage[],
tools: toolsDisabled ? undefined : tools.toAISDKFormat(),
maxOutputTokens,
abortSignal,
})
} catch (err: any) {
// Synchronous errors from streamText (rare — usually validation)
const { userMessage } = classifyAPIError(err)
if (!toolsDisabled && turn === 0) {
toolsDisabled = true
yield { type: 'error', message: `Tool calling failed: ${userMessage}. Retrying without tools.`, fatal: false }
continue
}
yield { type: 'error', message: userMessage, fatal: true }
return
}
// Collect tool calls emitted during this turn
const pendingToolCalls: Array<{
toolCallId: string
toolName: string
input: unknown
}> = []
let accumulatedText = ''
let streamError: any = null
try {
for await (const part of response.fullStream) {
switch (part.type) {
case 'text-delta':
if (part.text) {
accumulatedText += part.text
yield { type: 'text', content: part.text }
}
break
case 'tool-call':
pendingToolCalls.push({
toolCallId: part.toolCallId,
toolName: part.toolName,
input: part.input ?? {},
})
break
case 'reasoning-delta':
if (part.text) {
yield { type: 'thinking', content: part.text }
}
break
case 'error':
streamError = (part as any).error
break
}
}
} catch (err: any) {
// API errors during streaming (tool_calls format issues, provider errors, etc.)
const { userMessage } = classifyAPIError(err)
if (!toolsDisabled) {
// Retry without tools — strip tool history and restart from user messages only
toolsDisabled = true
// Reset history to original user messages (remove tool call/result entries)
history.length = 0
history.push(...messages)
turn = 0
yield { type: 'error', message: `Tool calling failed: ${userMessage}. Retrying without tools.`, fatal: false }
continue
}
yield { type: 'error', message: userMessage, fatal: true }
return
}
// Handle stream-level errors
if (streamError) {
yield { type: 'error', message: String(streamError), fatal: false }
}
// No tool calls means the model is done
if (!pendingToolCalls.length) {
yield { type: 'done', totalTurns: turn + 1 }
return
}
// Process each tool call: either execute directly or suspend for external resolution
const toolResults: Array<{ id: string; name: string; result: ToolResult }> = []
for (const toolCall of pendingToolCalls) {
const level = tools.getLevel(toolCall.toolName) ?? 'read'
yield {
type: 'tool_call',
id: toolCall.toolCallId,
name: toolCall.toolName,
args: toolCall.input,
level,
}
let toolResult: ToolResult
if (tools.hasExecute(toolCall.toolName)) {
try {
const tool = tools.get(toolCall.toolName)!
const data = await tool.execute!(toolCall.input)
toolResult = { success: true, data }
} catch (err) {
toolResult = { success: false, error: err instanceof Error ? err.message : JSON.stringify(err) }
}
} else {
toolResult = await waitForToolResult(toolCall.toolCallId)
}
toolResults.push({
id: toolCall.toolCallId,
name: toolCall.toolName,
result: toolResult,
})
yield {
type: 'tool_result',
id: toolCall.toolCallId,
name: toolCall.toolName,
result: toolResult,
}
}
// Use the SDK's own response messages for conversation history.
// This ensures correct format conversion (arguments stringification, etc.)
// instead of manually constructing ModelMessage[] which loses format details.
const resolved = await response.response
// response.messages contains assistant + auto-executed tool results in a format
// compatible with streamText(). Cast needed: ResponseMessage ≠ ModelMessage at type level.
const responseMessages = resolved.messages as unknown as ModelMessage[]
// The SDK includes assistant message + tool result messages (for tools with execute()).
// For tools WITHOUT execute (client-side execution), we only get the assistant message.
// We need to add tool result messages for those.
history.push(...responseMessages)
// Manually add tool results for client-executed tools (not auto-included by SDK).
// Structure matches the AI SDK's expected tool result format.
for (const tr of toolResults) {
if (!tools.hasExecute(tr.name)) {
history.push({
role: 'tool' as const,
content: [
{
type: 'tool-result' as const,
toolCallId: tr.id,
toolName: tr.name,
output: {
type: 'text' as const,
value: JSON.stringify(tr.result.data ?? tr.result.error ?? ''),
},
},
],
} as unknown as ModelMessage)
}
}
turn++
}
// Reached max turns — treat as a normal completion, not an error.
// The model may have done useful work even if it didn't finish cleanly.
yield { type: 'done', totalTurns: maxTurns }
} finally {
// Clean up pending tool calls to prevent timeout leaks
for (const [, entry] of pending) {
entry.reject(new Error('Agent loop ended'))
}
pending.clear()
}
}
return { run, resolveToolResult }
}

View file

@ -0,0 +1,162 @@
import type { ModelMessage } from 'ai'
import type { AgentProvider } from './providers/types'
import type { ToolRegistry } from './tools/tool-registry'
import type { AgentEvent } from './streaming/types'
import type { ContextStrategy } from './context/types'
import type { ToolResult } from './tools/types'
import { createAgent } from './agent-loop'
import { createDelegateTool } from './tools/delegate'
import { createToolRegistry } from './tools/tool-registry'
export interface TeamMemberConfig {
id: string
provider: AgentProvider
tools: ToolRegistry
systemPrompt: string
maxTurns?: number
turnTimeout?: number
contextStrategy?: ContextStrategy
}
export interface TeamConfig {
lead: {
provider: AgentProvider
tools: ToolRegistry
systemPrompt: string
maxTurns?: number
turnTimeout?: number
contextStrategy?: ContextStrategy
}
members: TeamMemberConfig[]
}
export interface AgentTeam {
run(messages: ModelMessage[]): AsyncGenerator<AgentEvent>
abort(): void
resolveToolResult(toolCallId: string, result: ToolResult): void
}
/** Build a team-awareness suffix for the lead's system prompt. */
function buildTeamSuffix(members: TeamMemberConfig[]): string {
const lines = members.map(m => {
const desc = m.systemPrompt.split('\n')[0] || 'Available for sub-tasks'
return `- **${m.id}**: ${desc}`
})
return `\n\n## Team Members\nYou have team members available via the \`delegate\` tool:\n${lines.join('\n')}\n\nWhen the user's request involves design or specialized work, delegate immediately — do not plan or describe the design yourself. Write a clear task for the member and let them handle it.\nHandle only conversation and simple queries yourself.`
}
export function createTeam(config: TeamConfig): AgentTeam {
const parentAbort = new AbortController()
const memberIds = config.members.map(m => m.id)
// Track which agent owns each pending tool call so resolveToolResult routes correctly.
// Without this, member tool calls (e.g. generate_design) would be sent to leadAgent
// which doesn't have them in its pending map, causing "No pending tool call" errors.
const toolCallOwners = new Map<string, { resolveToolResult: (id: string, result: ToolResult) => void }>()
// Clone tools to avoid mutating the caller's registry
const leadTools = createToolRegistry()
for (const tool of config.lead.tools.list()) {
leadTools.register(tool)
}
leadTools.register(createDelegateTool(memberIds))
const leadAgent = createAgent({
provider: config.lead.provider,
tools: leadTools,
systemPrompt: config.lead.systemPrompt + buildTeamSuffix(config.members),
maxTurns: config.lead.maxTurns ?? 30,
turnTimeout: config.lead.turnTimeout,
contextStrategy: config.lead.contextStrategy,
abortSignal: parentAbort.signal,
})
const memberAgents = new Map(
config.members.map(m => [
m.id,
{
config: m,
agent: createAgent({
provider: m.provider,
tools: m.tools,
systemPrompt: m.systemPrompt,
maxTurns: m.maxTurns ?? 20,
turnTimeout: m.turnTimeout,
contextStrategy: m.contextStrategy,
abortSignal: parentAbort.signal,
}),
},
]),
)
async function* run(messages: ModelMessage[]): AsyncGenerator<AgentEvent> {
for await (const event of leadAgent.run(messages)) {
if (event.type === 'tool_call' && event.name === 'delegate') {
const { member, task, context } = event.args as { member: string; task: string; context?: string }
const memberEntry = memberAgents.get(member)
if (!memberEntry) {
leadAgent.resolveToolResult(event.id, {
success: false,
error: `Unknown team member: ${member}`,
})
continue
}
// Yield the delegate tool_call with lead source
yield { ...event, source: 'lead' }
// Signal member start
yield { type: 'member_start', memberId: member, task }
const memberMessages: ModelMessage[] = [
{ role: 'user', content: typeof context === 'string' ? `${task}\n\nContext: ${context}` : task },
]
let memberResult = ''
for await (const memberEvent of memberEntry.agent.run(memberMessages)) {
// Register member tool calls for correct resolveToolResult routing
if (memberEvent.type === 'tool_call') {
toolCallOwners.set(memberEvent.id, memberEntry.agent)
}
// Tag all member events with source
yield { ...memberEvent, source: member } as AgentEvent
if (memberEvent.type === 'text') {
memberResult += memberEvent.content
}
}
// Signal member end
yield { type: 'member_end', memberId: member, result: memberResult || 'Task completed.' }
leadAgent.resolveToolResult(event.id, {
success: true,
data: memberResult || 'Task completed.',
})
continue
}
// Register lead tool calls for correct resolveToolResult routing
if (event.type === 'tool_call') {
toolCallOwners.set(event.id, leadAgent)
}
// Tag lead events with source
yield { ...event, source: 'lead' } as AgentEvent
}
}
return {
run,
abort: () => parentAbort.abort(),
resolveToolResult: (id, result) => {
const owner = toolCallOwners.get(id)
if (owner) {
toolCallOwners.delete(id)
owner.resolveToolResult(id, result)
} else {
// Fallback to lead for tool calls not tracked (e.g. delegate)
leadAgent.resolveToolResult(id, result)
}
},
}
}

View file

@ -0,0 +1,41 @@
import type { ModelMessage } from 'ai'
import type { ContextStrategy } from './types'
export interface SlidingWindowOptions {
maxTurns: number
}
export function createSlidingWindowStrategy(options: SlidingWindowOptions): ContextStrategy {
return {
// maxTokens is part of the ContextStrategy interface but unused by this turn-based strategy.
// A future token-counting strategy would use it for accurate budget trimming.
trim(messages: ModelMessage[], _maxTokens: number): ModelMessage[] {
const systemMessages = messages.filter(m => m.role === 'system')
const nonSystem = messages.filter(m => m.role !== 'system')
// Count "logical turns" — a turn starts at a user or assistant message
// and includes all following tool messages that belong to it.
// We must never split an assistant+tool group.
const turnStarts: number[] = []
for (let i = 0; i < nonSystem.length; i++) {
const role = nonSystem[i].role
if (role === 'user' || role === 'assistant') {
turnStarts.push(i)
}
// 'tool' messages belong to the preceding assistant turn — skip
}
if (turnStarts.length <= options.maxTurns) {
return [...systemMessages, ...nonSystem]
}
// Keep the last N turns (by their start indices)
const keepFrom = turnStarts[turnStarts.length - options.maxTurns]
const kept = nonSystem.slice(keepFrom)
// Ensure the first kept message is user or assistant, never tool
// (should already be the case since we cut at turnStarts)
return [...systemMessages, ...kept]
},
}
}

View file

@ -0,0 +1,5 @@
import type { ModelMessage } from 'ai'
export interface ContextStrategy {
trim(messages: ModelMessage[], maxTokens: number): ModelMessage[]
}

Some files were not shown because too many files have changed in this diff Show more