mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.6.0 (#88)
* 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:
parent
2cc2447ea0
commit
3c697d817d
151 changed files with 9474 additions and 925 deletions
130
.github/workflows/build-electron.yml
vendored
130
.github/workflows/build-electron.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
.github/workflows/publish-cli.yml
vendored
31
.github/workflows/publish-cli.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -11,9 +11,12 @@ __unconfig*
|
|||
todos.json
|
||||
.openpencil-tmp/
|
||||
|
||||
docs/
|
||||
|
||||
# Build outputs
|
||||
out/
|
||||
dist/
|
||||
dist-ssr/
|
||||
electron-dist/
|
||||
dist-electron/
|
||||
.claude
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
17
README.de.md
17
README.de.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.es.md
17
README.es.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.fr.md
17
README.fr.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.hi.md
17
README.hi.md
|
|
@ -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 भाषाएँ
|
||||
- [ ] सहयोगी संपादन
|
||||
- [ ] प्लगइन सिस्टम
|
||||
|
||||
|
|
|
|||
17
README.id.md
17
README.id.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.ja.md
17
README.ja.md
|
|
@ -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/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言語対応
|
||||
- [ ] 共同編集
|
||||
- [ ] プラグインシステム
|
||||
|
||||
|
|
|
|||
17
README.ko.md
17
README.ko.md
|
|
@ -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개 언어
|
||||
- [ ] 공동 편집
|
||||
- [ ] 플러그인 시스템
|
||||
|
||||
|
|
|
|||
52
README.md
52
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.pt.md
17
README.pt.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.ru.md
17
README.ru.md
|
|
@ -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 языков
|
||||
- [ ] Совместное редактирование
|
||||
- [ ] Система плагинов
|
||||
|
||||
|
|
|
|||
17
README.th.md
17
README.th.md
|
|
@ -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 ภาษา
|
||||
- [ ] การแก้ไขร่วมกัน
|
||||
- [ ] ระบบปลั๊กอิน
|
||||
|
||||
|
|
|
|||
17
README.tr.md
17
README.tr.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.vi.md
17
README.vi.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/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 提示詞技能引擎(分階段 prompt 載入)
|
||||
│ └── agent/ AI Agent SDK(Vercel 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 種語言
|
||||
- [ ] 協同編輯
|
||||
- [ ] 外掛程式系統
|
||||
|
||||
|
|
|
|||
17
README.zh.md
17
README.zh.md
|
|
@ -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/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 提示词技能引擎(分阶段 prompt 加载)
|
||||
│ └── agent/ AI Agent SDK(Vercel 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 种语言
|
||||
- [ ] 协同编辑
|
||||
- [ ] 插件系统
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ async function main(): Promise<void> {
|
|||
page: flags.page as string | undefined,
|
||||
}
|
||||
|
||||
|
||||
switch (command) {
|
||||
// --- App ---
|
||||
case 'start': {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@zseven-w/desktop",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
235
apps/web/server/api/ai/agent.ts
Normal file
235
apps/web/server/api/ai/agent.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
63
apps/web/server/api/ai/provider-models.ts
Normal file
63
apps/web/server/api/ai/provider-models.ts
Normal 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 }
|
||||
}
|
||||
})
|
||||
8
apps/web/server/api/mcp/sync-reset.post.ts
Normal file
8
apps/web/server/api/mcp/sync-reset.post.ts
Normal 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 }
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
23
apps/web/server/utils/agent-sessions.ts
Normal file
23
apps/web/server/utils/agent-sessions.ts
Normal 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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
184
apps/web/src/components/panels/ai-chat-model-selector.tsx
Normal file
184
apps/web/src/components/panels/ai-chat-model-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
152
apps/web/src/components/panels/tool-call-block.tsx
Normal file
152
apps/web/src/components/panels/tool-call-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
587
apps/web/src/components/shared/builtin-provider-settings.tsx
Normal file
587
apps/web/src/components/shared/builtin-provider-settings.tsx
Normal 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} · {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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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í',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 फ़ाइल ड्रॉप करें',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ファイルをここにドロップ',
|
||||
|
|
|
|||
|
|
@ -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 파일을 여기에 놓으세요',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 сюда',
|
||||
|
|
|
|||
|
|
@ -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 ที่นี่',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 檔案拖放至此處',
|
||||
|
|
|
|||
|
|
@ -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 文件拖放到此处',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
40
apps/web/src/mcp/tools/export-nodes.ts
Normal file
40
apps/web/src/mcp/tools/export-nodes.ts
Normal 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 ?? [],
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
492
apps/web/src/services/ai/agent-tool-executor.ts
Normal file
492
apps/web/src/services/ai/agent-tool-executor.ts
Normal 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 (border→strokes, 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 }
|
||||
}
|
||||
}
|
||||
103
apps/web/src/services/ai/agent-tools.ts
Normal file
103
apps/web/src/services/ai/agent-tools.ts
Normal 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 }
|
||||
|
|
@ -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.`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface ChatMessage {
|
|||
timestamp: number
|
||||
isStreaming?: boolean
|
||||
attachments?: ChatAttachment[]
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface AIDesignRequest {
|
||||
|
|
|
|||
454
apps/web/src/services/ai/code-generation-pipeline.ts
Normal file
454
apps/web/src/services/ai/code-generation-pipeline.ts
Normal 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)
|
||||
}
|
||||
111
apps/web/src/services/ai/codegen-prompts.ts
Normal file
111
apps/web/src/services/ai/codegen-prompts.ts
Normal 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'),
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
68
bun.lock
68
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
842
docs/superpowers/plans/2026-03-28-agent-team-web-integration.md
Normal file
842
docs/superpowers/plans/2026-03-28-agent-team-web-integration.md
Normal 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"
|
||||
```
|
||||
244
docs/superpowers/specs/2026-03-28-agent-team-web-integration.md
Normal file
244
docs/superpowers/specs/2026-03-28-agent-team-web-integration.md
Normal 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 |
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
83
packages/agent/__tests__/agent-loop.test.ts
Normal file
83
packages/agent/__tests__/agent-loop.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
49
packages/agent/__tests__/agent-team.test.ts
Normal file
49
packages/agent/__tests__/agent-team.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
83
packages/agent/__tests__/context/sliding-window.test.ts
Normal file
83
packages/agent/__tests__/context/sliding-window.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
22
packages/agent/__tests__/delegate.test.ts
Normal file
22
packages/agent/__tests__/delegate.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
24
packages/agent/__tests__/providers/anthropic.test.ts
Normal file
24
packages/agent/__tests__/providers/anthropic.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
24
packages/agent/__tests__/providers/openai-compat.test.ts
Normal file
24
packages/agent/__tests__/providers/openai-compat.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
42
packages/agent/__tests__/streaming/sse-decoder.test.ts
Normal file
42
packages/agent/__tests__/streaming/sse-decoder.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
45
packages/agent/__tests__/streaming/sse-encoder.test.ts
Normal file
45
packages/agent/__tests__/streaming/sse-encoder.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
45
packages/agent/__tests__/tool-registry.test.ts
Normal file
45
packages/agent/__tests__/tool-registry.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
23
packages/agent/package.json
Normal file
23
packages/agent/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
369
packages/agent/src/agent-loop.ts
Normal file
369
packages/agent/src/agent-loop.ts
Normal 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 }
|
||||
}
|
||||
162
packages/agent/src/agent-team.ts
Normal file
162
packages/agent/src/agent-team.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
41
packages/agent/src/context/sliding-window.ts
Normal file
41
packages/agent/src/context/sliding-window.ts
Normal 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]
|
||||
},
|
||||
}
|
||||
}
|
||||
5
packages/agent/src/context/types.ts
Normal file
5
packages/agent/src/context/types.ts
Normal 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
Loading…
Reference in a new issue