V0.7.2-bugfix (#109)

* Stabilize synced main for AI handoff, drag nesting, and Electron dev (#104)

* docs(readme): update cover screenshot

* fix: stabilize electron dev sync and codex env passthrough

* Preserve nested frame behavior during drag reparenting

Reparenting across containers used raw local coordinates and root-only clipping assumptions, which made nodes jump visually and caused dragged frames to lose clip/corner semantics after nesting. This adapts the drag-reparent fix to the current upstream store architecture, keeps frame/shape nodes from auto-detaching on canvas drags, and promotes formerly root-only frame clipping to explicit clipContent when nested.

Constraint: Latest upstream workspace checkout is incomplete locally (missing workspaces/deps), so full upstream verification could not be rerun in this environment
Rejected: Keep using raw local x/y during parent changes | fails for auto-layout/padding-rendered positions
Rejected: Make all nested frames clip unconditionally | would change non-clipping containers
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Preserve visual-position conversion through rendered coordinates when parent changes; local coordinates alone are insufficient once layout participates
Not-tested: Fresh full workspace typecheck/test/build on latest upstream checkout (blocked by missing workspace/dependency setup in this local clone)

* Keep AI codegen requests bounded while exporting asset bundles

The AI codegen pipeline needed two stability fixes: exported design images had to flow through chunk/assembly prompts as reusable asset hints, and oversized chat payloads needed a local guard before hitting provider limits. This commit wires asset extraction into the planning pipeline, threads exported asset paths into prompt assembly, and rejects obviously overlarge chat requests with an actionable client-side error.

Constraint: This branch is split out from a larger local fix stack, so only codegen/prompt/context files are included here
Constraint: Provider request limits are approximate locally, so the payload guard must be conservative rather than exact
Rejected: Inline base64 assets directly into prompts | explodes request size and repeats the same payload per chunk
Rejected: Let provider errors handle oversized payloads | too slow and opaque for users
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep asset references flowing as stable ./assets paths and enforce payload limits before fetch to avoid silent request bloat
Tested: bun x tsc -p apps/web/tsconfig.json --noEmit; cd apps/web && bun --bun vitest run src/services/ai/__tests__/context-optimizer.test.ts src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts; bun run build
Not-tested: Manual end-to-end AI generation with live providers

* Explain sanitized design views instead of leaving AI to guess

The sanitized structure bundle already stabilized asset paths, but it still exposed low-level image/layout/component fields that models had to interpret on their own. This change adds explicit consumer-view enrichment for fills, layout, text, variables, themes, and component semantics, carries original image size through the Figma import path, and augments sanitized bundles with summary/highlight guidance for downstream AI consumers.

Constraint: This branch is intentionally stacked on the asset-bundle PR because it extends the sanitized/codegen asset pipeline rather than replacing it
Constraint: Figma import data is not always complete, so original image size must be preserved when present and inferred only as a fallback downstream
Rejected: Keep sanitized.json as a pure field-level dump | still leaves AI to misread transforms, layout, and component relationships
Rejected: Put all explain text directly in asset extraction helpers | mixes resource stabilization with semantic enrichment responsibilities
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Treat consumer-view enrichment as a distinct layer on top of stable asset extraction; future AI-facing semantics should land there instead of leaking into unrelated pipeline code
Tested: bun x tsc -p apps/web/tsconfig.json --noEmit; cd apps/web && bun --bun vitest run src/services/ai/__tests__/consumer-view-enrichment.test.ts src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts ../../packages/pen-figma/src/figma-fill-mapper.test.ts; bun run build
Not-tested: Manual prompt-to-code generation quality with live provider responses

* Restore code-panel bundle exports for AI handoff flows

The code generation backend still produced asset manifests and AI structure bundles, but the code panel UI no longer exposed those export paths after later sync work. This commit reconnects the panel to bundle export actions, restores ZIP download behavior when generated code includes exported assets, and locks the affordances with focused panel tests.

Constraint: Other local fixes are still in progress in the working tree, so this commit is intentionally limited to the code-panel export surface
Rejected: Rebuild export support in a separate panel | users expect the export actions to remain where generation results are shown
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep code-panel UI aligned with codegen asset/bundle backends whenever generation result shape changes
Tested: cd apps/web && bun --bun vitest run src/components/panels/code-panel.test.tsx src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts; bun run build
Not-tested: Manual click-through of AI Bundle and Download ZIP in the desktop/web UI

* Unblock electron dev startup in the incomplete local workspace

The local workspace was failing before the app could even start: the skills plugin hard-required js-yaml from a node_modules layout that was not present, Vite dev under Bun hit Nitro NodeResponse incompatibilities, and the web tsconfig was missing path mappings for local packages. This commit removes the unnecessary js-yaml dependency from the skills loader, runs Vite under Node for dev startup, hardens readiness probing with socket checks, and points TypeScript/Vite at the in-repo package sources.

Constraint: The current local clone has incomplete hoisted/workspace installation state, so dev startup must not depend on root package links being perfectly present
Constraint: Bun + Nitro dev currently mis-handle NodeResponse in this environment, so the safest startup path is Node-hosted Vite
Rejected: Keep js-yaml and require everyone to fix local hoisting first | still leaves electron:dev broken in the current environment
Rejected: Continue running Vite dev through Bun | reproduces the NodeResponse/Parse Error failure on /api and /editor requests
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the dev launcher biased toward resilient local startup, even when the workspace install shape is imperfect
Tested: bun -e import('./packages/pen-ai-skills/vite-plugin-skills.ts').then(() => console.log('SKILL_PLUGIN_IMPORT_OK')); bun electron:dev verified Vite ready, MCP/Electron compiled, Electron launched, MCP sync log emitted
Not-tested: Long-running interactive desktop session after startup

* fix(figma): preserve cropped image fill transforms

The synced branch started exporting original image dimensions but dropped the
existing crop transform semantics from the shared image-fill type and both
Figma mappers. That broke the new regression test and stripped metadata that
AI consumer-view/bundle code already relies on.

Constraint: keep app and package Figma mappers in lockstep
Rejected: loosen the new regression test | would hide a real metadata regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: when extending image fill metadata, update shared pen-types and both Figma mapper copies together
Tested: bun --bun run test (148/149 files passed; only server/__tests__/sse-keepalive.test.ts blocked by missing agent_napi.node), cd apps/web && bun --bun vitest run src/canvas/skia/drag-reparent-policy.test.ts src/components/panels/layer-dnd-utils.test.ts src/stores/document-position-utils.test.ts src/components/panels/code-panel.test.tsx ../../packages/pen-renderer/src/__tests__/document-flattener.test.ts ../../packages/pen-figma/src/figma-fill-mapper.test.ts, cd apps/web && bun --bun vitest run src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts src/services/ai/__tests__/consumer-view-enrichment.test.ts, cd apps/web && bun --bun vitest run src/utils/__tests__/security.test.ts, bun test scripts/loopback-no-proxy.test.ts, npx tsc --noEmit, bun --bun run build
Not-tested: server/__tests__/sse-keepalive.test.ts without a locally built @zseven-w/agent-native addon

* docs(editor): normalize new PR comments to English

The PR had a handful of newly introduced Chinese code comments in dev, sync, and AI helper paths. This follow-up keeps the implementation unchanged while translating those comments to English so the PR stays consistent with the repository comment-language expectation.

Constraint: The request was limited to comment language cleanup after the conflict-resolution merge, so behavior had to remain unchanged
Rejected: Leave the mixed-language comments in place | conflicts with the PR requirement for English comments
Rejected: Broader repository-wide translation sweep | unnecessary scope expansion beyond the PR-introduced comments
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep code comments in English on this branch, even when local notes or working memory are in another language
Tested: bun test scripts/loopback-no-proxy.test.ts apps/desktop/__tests__/dev-utils.test.ts; cd apps/web && bun --bun vitest run server/__tests__/mcp-sync-state-active.test.ts src/canvas/skia/__tests__/skia-interaction.test.ts; npx tsc --noEmit; branch-diff comment scan for Han characters in comment lines
Not-tested: Manual runtime behavior, since this change only rewrote comments

* style(editor): apply repository formatting expected by CI

The PR was failing the CI Format check after the conflict-resolution and comment-normalization follow-ups. This commit applies the repository formatter output to the files touched by the branch so CI sees the exact formatting it expects, without changing behavior.

Constraint: The failing GitHub Actions job stopped at Format check, so the fix had to match oxfmt output rather than introduce functional changes
Rejected: Leave the branch as-is and rely on local formatting differences being acceptable | CI explicitly rejects the current formatting
Rejected: Broader code cleanup beyond formatter output | unnecessary scope while repairing the failing check
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: After conflict resolution or comment-only edits on this repo, run bun run format:check before pushing because formatter expectations are stricter than the existing file style in some touched files
Tested: bun run format:check; bun run lint; npx tsc --noEmit
Not-tested: Full test suite after this formatting-only commit (previous run showed formatting was the first CI blocker)

* refactor(editor): remove proxy-specific dev workarounds from PR

The PR no longer needs the loopback proxy bypass layer, so this cleanup removes the proxy-specific dev entrypoint, environment bootstrap, helper module, and its tests while keeping the unrelated Electron and AI handoff changes intact.

Constraint: Removal had to be limited to proxy-related code on PR #104 without undoing the other merged fixes on the branch
Rejected: Keep the helper and stop using it | leaves proxy-specific maintenance surface and tests in the PR
Rejected: Revert the entire Electron dev file to upstream earlier than necessary | would risk dropping unrelated local conflict-resolution choices beyond the proxy scope
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If proxy handling is reintroduced later, keep it out of this PR unless there is a dedicated, separately justified change for it
Tested: bun run format:check; bun run lint; npx tsc --noEmit
Not-tested: Manual electron:dev behavior after removing the proxy-specific launcher path

* docs(ai): translate JSON-facing semantic descriptions to English

The PR still emitted Chinese semantic description strings inside the AI consumer-view and structure-bundle JSON outputs. This change translates those JSON-facing runtime descriptions and updates the affected tests so exported AI-facing structure data is consistently English.

Constraint: The request was limited to JSON description strings, so the change had to preserve the same semantics and structure while only translating output text
Rejected: Leave Chinese test fixtures and runtime descriptions in place | conflicts with the requirement for English JSON descriptions
Rejected: Broader i18n cleanup outside these AI JSON description paths | unnecessary scope expansion beyond the requested exported-description surface
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep AI/exported JSON explanation strings in English unless a future change explicitly adds localized output modes
Tested: cd apps/web && bun --bun vitest run src/services/ai/__tests__/consumer-view-enrichment.test.ts src/services/ai/__tests__/structure-bundle.test.ts src/services/ai/__tests__/codegen-assets.test.ts; bun run format:check; npx tsc --noEmit
Not-tested: Full app runtime flows that consume these JSON descriptions outside the covered unit tests

* refactor(ai): remove remaining network-proxy handling

The current project still carried Anthropic proxy-specific heuristics and environment handling outside the PR-specific cleanup. Since the earlier crashes and connectivity issues were unrelated to proxying, this removes the remaining network-proxy branches, model remapping, and TLS override advice while leaving unrelated request flows intact.

Constraint: The cleanup needed to remove proxy-specific logic without disturbing unrelated transport concepts such as app-internal API proxy routes or React proxy objects used in tests
Rejected: Keep the proxy heuristics as dormant fallback logic | preserves misleading operational guidance and dead maintenance surface
Rejected: Rename every remaining literal use of the word proxy in the repo | would overreach into unrelated concepts like internal API proxying and JS Proxy-based test setup
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If endpoint-specific compatibility logic is needed later, add it as explicit endpoint handling rather than generic proxy heuristics
Tested: bun run format:check; bun run lint; npx tsc --noEmit; repo-wide search for network-proxy env references after cleanup
Not-tested: End-to-end Claude connection flows against custom base URLs after removing proxy-specific remapping

* fix(electron): keep Node-backed dev launch for Nitro compatibility

Comparing against upstream commit 7271a03 confirms the current Electron dev fix is not the same idea as the original Bun-based launcher. The upstream version starts Vite with Bun, while the observed failure shows Nitro now crashes in that path with "Vite environment nitro is unavailable". This keeps the non-proxy Node-backed launcher because it fixes the actual regression without restoring the removed proxy code.

Constraint: The request preferred reverting to the upstream original only if the intent matched, but the current Nitro/Electron failure proves the upstream Bun launcher is no longer equivalent in behavior
Rejected: Restore the exact 7271a03 Bun launcher | reproduces the Nitro dev-worker crash and ERR_EMPTY_RESPONSE in Electron
Rejected: Reintroduce the old proxy workaround bundle | unrelated to the reproduced failure and already removed by request
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Electron dev on the Node-backed Vite launcher unless Nitro/Bun dev compatibility is revalidated with a real startup test
Tested: bun run electron:dev (reached Electron launch after Vite/MCP/Electron compile steps); bun test apps/desktop/__tests__/dev-utils.test.ts; bun run format:check; npx tsc --noEmit
Not-tested: Full interactive manual editor workflow after Electron launch

---------

Co-authored-by: Fini <fini.yang@gmail.com>

* fix(ai,cli): openai-compat turn-2, StepFun reasoning+451, Mac CLI discovery

Round up the v0.7.2 stability fixes for AI connectivity and local CLI
detection that surfaced during real user runs against GLM, StepFun, and
Mac users on nvm/fnm/pnpm/bun/mise/asdf/fish shells.

Provider (via @zseven-w/agent-native v0.3.0 submodule bump):
- OpenAI-compat providers can now complete multi-turn tool-calling loops:
  the request builder translates Anthropic-shaped message history
  (tool_use / tool_result blocks, thinking) into OpenAI's tool_calls +
  role="tool" form so turn 2 no longer 400s. system_prompt is finally
  injected instead of being silently dropped.
- The SSE parser accepts `delta.reasoning` (StepFun step_plan) alongside
  `reasoning_content` (GLM / DeepSeek / Qwen), and also streams tool_call
  fragments, which unblocks GLM / dashscope and stops the
  firstTextTimeout → fetch abort → std.http panic → Bun segfault cascade.
- HTTP 451 (StepFun content-safety) surfaces as InvalidRequest with a
  specific "content blocked by provider safety filter" message instead
  of an opaque error_server.

Server route + client watchdog:
- /api/ai/chat forwards the provider's last_error string
  (result.errors[0]) so users see "HTTP 451 content blocked" rather than
  "Provider error: error_server".
- streamChat clears firstTextTimeout on thinking chunks (when
  thinkingResetsTimeout=true), so models that stream long reasoning
  before any text aren't falsely killed as "stuck".

Orchestrator sub-agent resilience:
- Failed sub-agents (empty response / unparseable output) now retry once
  with a minimal ~3KB kernel prompt (schema + jsonl-format only). Only
  the failing subtask re-runs — successful earlier sections are kept.
- Deterministic refusals (HTTP 400/401/429/451, "content blocked",
  "censorship", "authentication failed") short-circuit the retry ladder
  so a 4-minute StepFun safety scan isn't spent twice in a row.

Local CLI discovery (Mac users on managed shells):
- New server/utils/cli-resolver-helpers.ts exports probeViaLoginShell()
  and posixUserBinDirs(). Login-shell probe asks $SHELL (or zsh/bash
  fallback — fish added at /opt/homebrew/bin/fish and friends) with
  `-ilc 'command -v <cli>'` so nvm/pnpm/bun/mise/asdf/volta/fnm shims
  are visible even when Electron scrubs the inherited PATH.
- resolveClaudeCli / resolveGeminiCli / resolveCopilotCli and the
  inline codex/opencode resolvers in connect-agent.ts all run the same
  PATH → login-shell → npm-prefix → user-bin candidates ladder. Each
  step logs via serverLog to ~/.openpencil/logs/server-YYYY-MM-DD.log
  for remote diagnosis.

Builtin provider preset:
- Add StepFun Coding Plan (api.stepfun.com/step_plan/v1, label "StepFun
  Coding Plan") alongside the existing StepFun preset.

Version bump 0.7.1 → 0.7.2 across all workspaces.

---------

Co-authored-by: RaisCui <857943+raiscui@users.noreply.github.com>
Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-04-14 21:42:56 +08:00 committed by GitHub
parent 7271a03833
commit 904c033290
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 3583 additions and 253 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/openpencil",
"version": "0.7.1",
"version": "0.7.2",
"description": "CLI for OpenPencil — control the design tool from your terminal",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/apps/cli",
"bugs": {

View file

@ -14,12 +14,17 @@ import { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills';
import { getElectronBinaryPath, getElectronSpawnEnv } from './dev-utils';
import {
getDevServerConflictMessage,
getElectronBinaryPath,
getElectronSpawnEnv,
} from './dev-utils';
const DESKTOP_DIR = import.meta.dirname;
const ROOT = join(DESKTOP_DIR, '..', '..');
const WEB_DIR = join(ROOT, 'apps', 'web');
const VITE_DEV_PORT = 3000;
const VITE_CLI = join(ROOT, 'node_modules', 'vite', 'bin', 'vite.js');
const GENERATED_SKILL_REGISTRY = join(
ROOT,
'packages',
@ -36,17 +41,17 @@ const GENERATED_SKILL_REGISTRY = join(
async function waitForViteServer(
baseUrl: string,
vite: ChildProcess,
port: number,
timeoutMs = 30_000,
): Promise<void> {
const target = new URL(baseUrl);
const port = Number.parseInt(target.port || '80', 10);
const hosts =
target.hostname === 'localhost' ? ['127.0.0.1', '::1', 'localhost'] : [target.hostname];
const start = Date.now();
let viteExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
viteExit = { code, signal };
};
const target = new URL(baseUrl);
const hosts =
target.hostname === 'localhost' ? ['127.0.0.1', '::1', 'localhost'] : [target.hostname];
async function canConnect(host: string): Promise<boolean> {
return await new Promise((resolve) => {
@ -68,6 +73,46 @@ async function waitForViteServer(
vite.once('exit', handleExit);
while (Date.now() - start < timeoutMs) {
let baseReachable = false;
let viteClientReachable = false;
let viteClientStatus: number | null = null;
try {
const res = await fetch(baseUrl, {
signal: AbortSignal.timeout(500),
});
baseReachable = res.ok || res.status < 500;
} catch {
// Server not ready yet.
}
try {
const res = await fetch(`${baseUrl}/@vite/client`, {
signal: AbortSignal.timeout(500),
});
viteClientStatus = res.status;
viteClientReachable = res.ok;
if (viteClientReachable) {
vite.off('exit', handleExit);
return;
}
} catch {
// Vite client not ready yet.
}
const conflict = getDevServerConflictMessage(
{
baseReachable,
viteClientReachable,
viteClientStatus,
},
port,
);
if (conflict) {
vite.off('exit', handleExit);
throw new Error(conflict);
}
for (const host of hosts) {
if (await canConnect(host)) {
vite.off('exit', handleExit);
@ -123,10 +168,10 @@ async function compileElectron(): Promise<void> {
async function main(): Promise<void> {
// 1. Start Vite dev server
console.log('[electron-dev] Starting Vite dev server...');
// Launch Vite directly on Windows. Spawning through `bun run dev` can tear
// down the inner `vite.exe` process after startup, leaving Electron with a
// ready log but no live dev server to connect to.
const vite = spawn('bun', ['--bun', 'vite', 'dev', '--port', String(VITE_DEV_PORT)], {
// Run Vite under Node, not Bun. Nitro's dev worker currently expects the
// Node-backed environment and can crash under Bun with "Vite environment
// nitro is unavailable" during /editor or /api requests.
const vite = spawn('node', [VITE_CLI, 'dev', '--port', String(VITE_DEV_PORT)], {
cwd: WEB_DIR,
stdio: 'inherit',
env: { ...process.env },
@ -185,7 +230,7 @@ async function main(): Promise<void> {
// 2. Wait for Vite to be ready
console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`);
try {
await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite);
await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite, VITE_DEV_PORT);
} catch (error) {
stopVite();
throw error;

View file

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

18
apps/web/dev.ts Normal file
View file

@ -0,0 +1,18 @@
import { spawn } from 'node:child_process';
import { join } from 'node:path';
const VITE_CLI = join(import.meta.dirname, '..', '..', 'node_modules', 'vite', 'bin', 'vite.js');
const child = spawn('node', [VITE_CLI, 'dev', '--port', '3000'], {
cwd: import.meta.dirname,
stdio: 'inherit',
env: { ...process.env },
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/web",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"type": "module",
"dependencies": {

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { filterCodexEnv } from '../utils/codex-client';
import { extractCodexConfigEnvKeys, filterCodexEnv } from '../utils/codex-client';
import { SENSITIVE_LOG_PATTERN, ALLOWED_MEDIA_TYPES, resolveMediaExtension } from '../api/ai/chat';
// ---------------------------------------------------------------------------
@ -56,6 +56,28 @@ describe('codex client env allowlist', () => {
expect(filtered.CODEX_SANDBOX).toBe('read-only');
});
it('should keep custom provider env keys declared in codex config', () => {
const env = {
PATH: '/usr/bin',
HOME: '/home/user',
agw_CODE: 'sk-agw-xxx',
PACKYCODE: 'sk-packy-xxx',
OTHER_SECRET: 'bad',
};
const extraAllowed = extractCodexConfigEnvKeys(`
[model_providers.agw]
env_key = "agw_CODE"
[model_providers.packycode]
env_key = "PACKYCODE"
`);
const filtered = filterCodexEnv(env, extraAllowed);
expect(filtered.agw_CODE).toBe('sk-agw-xxx');
expect(filtered.PACKYCODE).toBe('sk-packy-xxx');
expect(filtered).not.toHaveProperty('OTHER_SECRET');
});
it('should not leak vars with similar prefixes', () => {
const env = {
PATH: '/usr/bin',

View file

@ -9,7 +9,6 @@ import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
resolveAgentModel,
} from '../../utils/resolve-claude-agent-env';
import { normalizeOptionalBaseURL, requireOpenAICompatBaseURL } from './provider-url';
// SENSITIVE_LOG_PATTERN + readDebugTail are now canonical in @zseven-w/pen-mcp.
@ -72,7 +71,7 @@ function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | u
/Connection error|Could not resolve host|Failed to connect|ECONNREFUSED|ETIMEDOUT/i.test(text)
) {
hints.push(
'Upstream API connection failed. Check proxy/DNS/network reachability to your ANTHROPIC_BASE_URL.',
'Upstream API connection failed. Check DNS and network reachability to your configured endpoint.',
);
}
if (/ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false/i.test(text)) {
@ -89,28 +88,16 @@ function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | u
}
if (/ENOTFOUND|getaddrinfo/i.test(text)) {
hints.push(
'DNS resolution failed for the API endpoint. Check your ANTHROPIC_BASE_URL is correct.',
'DNS resolution failed for the API endpoint. Check that your configured endpoint is correct.',
);
}
if (/certificate|CERT_|ssl|tls/i.test(text)) {
hints.push(
'TLS/SSL certificate error. If using a corporate proxy, set NODE_TLS_REJECT_UNAUTHORIZED=0 in ~/.claude/settings.json env (not recommended for production).',
'TLS/SSL certificate error. Check the endpoint certificate chain and your local trust settings.',
);
}
}
// Detect proxy/custom endpoint — most common cause of exit-code-1 on Windows
const env = process.env;
const hasProxy = !!(env.http_proxy || env.https_proxy || env.HTTP_PROXY || env.HTTPS_PROXY);
const hasCustomBaseUrl = !!env.ANTHROPIC_BASE_URL;
if ((hasProxy || hasCustomBaseUrl) && !env.NODE_TLS_REJECT_UNAUTHORIZED) {
hints.push(
'Proxy or custom ANTHROPIC_BASE_URL detected but NODE_TLS_REJECT_UNAUTHORIZED is not set. ' +
'If your proxy uses a self-signed or corporate certificate, add ' +
'"NODE_TLS_REJECT_UNAUTHORIZED": "0" to the env section of ~/.claude/settings.json.',
);
}
// If no debug info available, provide generic Windows guidance
if (hints.length === 0) {
const isWin = process.platform === 'win32';
@ -118,8 +105,7 @@ function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | u
hints.push(
'Claude Code process crashed on Windows. Common fixes: ' +
'(1) Ensure ~/.claude.json exists: echo {} > %USERPROFILE%\\.claude.json ' +
'(2) Check ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL in ~/.claude/settings.json ' +
'(3) If using a proxy, set NODE_TLS_REJECT_UNAUTHORIZED=0 in env.',
'(2) Check your Claude authentication and endpoint configuration in ~/.claude/settings.json.',
);
} else {
return undefined;
@ -294,11 +280,7 @@ function streamViaAgentSDK(body: ChatBody, requestedModel?: string) {
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = buildClaudeAgentEnv();
debugFile = getClaudeAgentDebugFilePath();
// When using a custom proxy (ANTHROPIC_BASE_URL), skip explicit model
// so Claude Code uses ANTHROPIC_MODEL from env — the proxy may not
// recognize standard Claude model IDs.
const model = resolveAgentModel(requestedModel, env);
const model = requestedModel;
const claudePath = resolveClaudeCli();
const spawnProcess = buildSpawnClaudeCodeProcess();
@ -1092,7 +1074,16 @@ function streamViaBuiltin(body: ChatBody) {
),
);
} else if (evt.result?.is_error) {
const errMsg = `Provider error: ${evt.result.subtype ?? 'unknown'}`;
// Zig attaches the provider's last_error string in result.errors[0]
// (e.g. "Content blocked by provider safety filter (HTTP 451)...").
// Surface that instead of the opaque subtype so users see the
// actual reason — "content blocked" vs. "rate limit" vs. "auth"
// is information they can act on.
const detail =
(Array.isArray(evt.result.errors) && evt.result.errors[0]) ||
evt.result.subtype ||
'unknown';
const errMsg = `Provider error: ${detail}`;
console.error('[builtin]', errMsg);
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content: errMsg })}\n\n`),

View file

@ -4,6 +4,7 @@ import { join } from 'node:path';
import type { GroupedModel } from '../../../src/types/agent-settings';
import { resolveClaudeCli } from '../../utils/resolve-claude-cli';
import { serverLog } from '../../utils/server-logger';
import { posixUserBinDirs, probeViaLoginShell } from '../../utils/cli-resolver-helpers';
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
@ -209,11 +210,13 @@ async function connectClaudeCode(): Promise<ConnectResult> {
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to connect';
serverLog.error(`[connect-agent] claude connection error: ${msg}`);
// Third-party API proxies often don't support the supportedModels() call,
// Some custom base-URL setups do not support the supportedModels() call,
// causing "query closed before response". Fall back to a default model list
// so users can still connect and choose a model.
if (/closed before|closed early|query closed/i.test(msg)) {
serverLog.info('[connect-agent] using fallback model list (proxy detected)');
serverLog.info(
'[connect-agent] using fallback model list after supportedModels() closed early',
);
const fallbackEnv = buildClaudeAgentEnv();
const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null);
@ -230,7 +233,7 @@ async function connectClaudeCode(): Promise<ConnectResult> {
// Surface specific issues as warnings
if (/certificate|CERT_|ssl|tls/i.test(tail)) {
warning =
'TLS/SSL error detected. If using a proxy, add "NODE_TLS_REJECT_UNAUTHORIZED": "0" to ~/.claude/settings.json env.';
'TLS/SSL error detected. Check the endpoint certificate chain and local trust configuration.';
} else if (/EPERM|operation not permitted/i.test(tail)) {
warning =
'Permission error writing config. Try: echo {} > %USERPROFILE%\\.claude.json';
@ -282,7 +285,7 @@ function buildClaudeConnectionInfo(
return { connectionInfo: `Connected via ${sub} (${account.email})`, hintPath: hp };
}
if (apiKey && baseUrl) {
return { connectionInfo: 'Connected via API key (custom endpoint)', hintPath: hp };
return { connectionInfo: 'Connected via API key (custom base URL)', hintPath: hp };
}
if (apiKey) {
const masked = apiKey.length > 12 ? `${apiKey.slice(0, 8)}...` : '***';
@ -419,7 +422,11 @@ async function connectCodexCli(): Promise<ConnectResult> {
const { join } = await import('node:path');
const isWin = process.platform === 'win32';
// Check if codex binary exists — PATH, npm prefix, then common locations
serverLog.info(
`[connect-agent] codex platform=${process.platform}, SHELL=${process.env.SHELL ?? 'unset'}`,
);
// Check if codex binary exists — PATH, login shell (macOS/Linux), npm prefix, common locations
let which = '';
// 1. PATH lookup
@ -444,7 +451,13 @@ async function connectCodexCli(): Promise<ConnectResult> {
);
}
// 2. npm prefix -g (Windows: npm global creates .cmd or .ps1 wrappers)
// 2. macOS/Linux: probe the user's login shell for nvm/pnpm/bun/mise shims
if (!which && !isWin) {
const viaShell = probeViaLoginShell('codex', 'connect-agent:codex');
if (viaShell) which = viaShell;
}
// 3. npm prefix -g (Windows: npm global creates .cmd or .ps1 wrappers)
if (!which && isWin) {
try {
serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g');
@ -471,13 +484,15 @@ async function connectCodexCli(): Promise<ConnectResult> {
}
}
// 3. Common install locations
if (!which && isWin) {
const candidates = [
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'codex'),
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'codex'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'codex'),
];
// 4. Common install locations
if (!which) {
const candidates = isWin
? [
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'codex'),
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'codex'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'codex'),
]
: posixUserBinDirs().map((dir) => join(dir, 'codex'));
for (const c of candidates) {
const exists = c ? existsSync(c) : false;
serverLog.info(`[connect-agent] codex candidate: "${c}" (exists=${exists})`);
@ -533,7 +548,7 @@ async function connectCodexCli(): Promise<ConnectResult> {
}));
}
} catch {
serverLog.info(`[connect-agent] codex models cache not available`);
serverLog.info('[connect-agent] codex models cache not available');
}
// Fallback: parse models from Codex's bundled latest-model.md reference
@ -568,7 +583,9 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
const { join } = await import('node:path');
const isWin = process.platform === 'win32';
serverLog.info(`[resolve-opencode] platform=${process.platform}, isWindows=${isWin}`);
serverLog.info(
`[resolve-opencode] platform=${process.platform}, isWindows=${isWin}, SHELL=${process.env.SHELL ?? 'unset'}`,
);
// 1. Try PATH lookup
try {
@ -588,7 +605,13 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
);
}
// 2. Try `npm prefix -g` to find actual npm global bin directory
// 2. macOS/Linux login-shell probe for nvm/pnpm/bun/mise/asdf shims
if (!isWin) {
const viaShell = probeViaLoginShell('opencode', 'resolve-opencode');
if (viaShell) return viaShell;
}
// 3. Try `npm prefix -g` to find actual npm global bin directory
// On Windows, must use `npm.cmd` since Electron spawns cmd.exe
try {
const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g';
@ -613,10 +636,7 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
);
}
// 3. Common install locations
// npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux)
// curl installer → ~/.opencode/bin (macOS/Linux)
// Homebrew → /usr/local/bin or /opt/homebrew/bin (macOS)
// 4. Common install locations
const home = homedir();
const candidates = isWin
? [
@ -633,12 +653,10 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
: [
// curl installer (https://opencode.ai/install)
join(home, '.opencode', 'bin', 'opencode'),
// npm global
// npm global (non-standard prefix)
join(home, '.npm-global', 'bin', 'opencode'),
'/usr/local/bin/opencode',
// Homebrew
'/opt/homebrew/bin/opencode',
join(home, '.local', 'bin', 'opencode'),
// All the common user-local/package-manager bin dirs
...posixUserBinDirs().map((dir) => join(dir, 'opencode')),
];
for (const c of candidates) {
const exists = c ? existsSync(c) : false;
@ -646,7 +664,9 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
if (c && exists) return c;
}
serverLog.info('[resolve-opencode] no opencode binary found');
serverLog.warn(
'[resolve-opencode] no opencode binary found after PATH, login-shell probe, and candidate scan',
);
return undefined;
}

View file

@ -5,7 +5,6 @@ import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
resolveAgentModel,
} from '../../utils/resolve-claude-agent-env';
import { formatOpenCodeError } from './chat';
import { createSSEResponse } from '../../utils/sse-stream';
@ -69,7 +68,7 @@ async function generateViaAgentSDK(
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = buildClaudeAgentEnv();
const debugFile = getClaudeAgentDebugFilePath();
const model = resolveAgentModel(requestedModel, env);
const model = requestedModel;
const claudePath = resolveClaudeCli();
@ -310,7 +309,7 @@ function streamViaAgentSDK(body: GenerateBody, requestedModel?: string) {
const { query } = await import('@anthropic-ai/claude-agent-sdk');
const env = buildClaudeAgentEnv();
const debugFile = getClaudeAgentDebugFilePath();
const model = resolveAgentModel(requestedModel, env);
const model = requestedModel;
const claudePath = resolveClaudeCli();
const q = query({

View file

@ -4,7 +4,6 @@ import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
resolveAgentModel,
} from '../../utils/resolve-claude-agent-env';
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -109,7 +108,7 @@ async function validateViaAgentSDK(
const env = buildClaudeAgentEnv();
const debugFile = getClaudeAgentDebugFilePath();
const claudePath = resolveClaudeCli();
const model = resolveAgentModel(requestedModel, env);
const model = requestedModel;
const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design.

View file

@ -0,0 +1,152 @@
/**
* Shared helpers for resolving local CLI binaries across the builtin agent
* providers. Electron on macOS does NOT inherit the user's login-shell PATH,
* so a bare `which <cli>` from Nitro often fails even when the CLI works
* in the user's terminal. Common install locations like ~/.bun/bin,
* ~/.nvm/versions/node/<ver>/bin, ~/Library/pnpm, ~/.volta/bin,
* ~/.asdf/shims, ~/.local/share/mise/shims are invisible to the server.
*
* Two primitives here:
* - posixUserBinDirs(): enumerate the standard user-local / package-manager
* bin directories, so resolvers can scan them as concrete candidates.
* - probeViaLoginShell(binary): ask the user's configured shell for the
* resolved path of a binary, picking up nvm / pnpm / bun / mise / asdf
* shims without having to enumerate every possible install layout.
*
* Everything logs through serverLog so the server-YYYY-MM-DD.log file gets
* the full resolution trace for remote diagnosis.
*/
import { execSync } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { join } from 'node:path';
import { serverLog } from './server-logger';
const isWindows = platform() === 'win32';
/** Enumerate the standard macOS/Linux user-local install directories. */
export function posixUserBinDirs(): string[] {
const home = homedir();
const dirs = [
join(home, '.bun', 'bin'),
join(home, '.volta', 'bin'),
join(home, '.local', 'bin'),
join(home, '.local', 'share', 'mise', 'shims'),
join(home, '.asdf', 'shims'),
join(home, 'Library', 'pnpm'),
join(home, '.pnpm-global', 'bin'),
join(home, '.cargo', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
];
// nvm: enumerate installed node versions best-effort (just readdir)
try {
const nvmNodeRoot = join(home, '.nvm', 'versions', 'node');
if (existsSync(nvmNodeRoot)) {
for (const ver of readdirSync(nvmNodeRoot)) {
dirs.push(join(nvmNodeRoot, ver, 'bin'));
}
}
} catch {
/* best effort */
}
// fnm
try {
const fnmRoot = join(home, '.fnm', 'node-versions');
if (existsSync(fnmRoot)) {
for (const ver of readdirSync(fnmRoot)) {
dirs.push(join(fnmRoot, ver, 'installation', 'bin'));
}
}
} catch {
/* best effort */
}
return dirs;
}
/**
* Standard locations where fish is typically installed on macOS/Linux.
* Homebrew Apple Silicon uses /opt/homebrew, Homebrew Intel + most distros
* ship to /usr/local, MacPorts uses /opt/local.
*/
const FISH_FALLBACK_PATHS = [
'/opt/homebrew/bin/fish',
'/usr/local/bin/fish',
'/opt/local/bin/fish',
'/usr/bin/fish',
];
/**
* Probe the user's login shell for the resolved path of a binary.
* Runs `<shell> -ilc 'command -v <binary>'` to source the user's rc + profile
* so nvm / pnpm / bun / mise / asdf shims wired there are visible. `prefix`
* is used only for the log lines to match the caller's existing namespace.
*
* Supports zsh, bash, and fish fish users configure their PATH in
* `~/.config/fish/config.fish` (often via nvm.fish / mise / bass), which
* neither zsh nor bash will source. `command -v` and `2>/dev/null` both
* work in fish 3.x, so the invocation is identical across shells.
*/
export function probeViaLoginShell(binary: string, prefix: string): string | undefined {
if (isWindows) return undefined;
const userShell = process.env.SHELL;
const shells: string[] = [];
// User's declared shell wins — the one that sources their real rc/profile.
if (userShell && existsSync(userShell)) shells.push(userShell);
// Fish fallback: many users run fish but Electron may launch with SHELL
// unset (e.g. Dock-launched apps inherit a scrubbed environment). Without
// this, fish-only PATH entries (nvm.fish / mise / bass) stay invisible.
if (!shells.some((s) => s.endsWith('/fish'))) {
for (const p of FISH_FALLBACK_PATHS) {
if (existsSync(p)) {
shells.push(p);
break;
}
}
}
// zsh / bash catch-all — most POSIX CLIs wire their shims through at least
// one of these even on fish-primary systems.
if (!shells.some((s) => s.endsWith('/zsh')) && existsSync('/bin/zsh')) {
shells.push('/bin/zsh');
}
if (!shells.some((s) => s.endsWith('/bash')) && existsSync('/bin/bash')) {
shells.push('/bin/bash');
}
for (const shell of shells) {
try {
const cmd = `${shell} -ilc 'command -v ${binary} 2>/dev/null' 2>/dev/null`;
serverLog.info(`[${prefix}] login-shell probe: ${cmd}`);
const raw = execSync(cmd, {
encoding: 'utf-8',
timeout: 6000,
// Start from a minimal env — inheriting Electron's env can suppress
// the login-shell side effects we rely on (nvm's __NVM_DIR etc).
env: { HOME: homedir(), USER: process.env.USER ?? '' },
}).trim();
const path = raw.split(/\r?\n/).filter(Boolean).pop();
if (path && existsSync(path)) {
serverLog.info(`[${prefix}] login-shell probe hit via ${shell}: "${path}"`);
return path;
}
if (path) {
serverLog.info(
`[${prefix}] login-shell (${shell}) returned "${path}" but file does not exist`,
);
}
} catch (err) {
serverLog.info(
`[${prefix}] login-shell probe via ${shell} failed: ${err instanceof Error ? err.message : err}`,
);
}
}
return undefined;
}

View file

@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
type ThinkingMode = 'adaptive' | 'disabled' | 'enabled';
@ -50,12 +51,44 @@ const CODEX_ENV_ALLOWLIST = new Set([
'HOMEPATH',
]);
/**
* Extract provider-declared env_key entries from ~/.codex/config.toml.
*
* This preserves the default safety boundary of not forwarding sensitive
* environment variables automatically, while still letting user-defined
* Codex providers opt in through config.toml as the single source of truth.
*/
export function extractCodexConfigEnvKeys(configToml: string): string[] {
return Array.from(
new Set(
Array.from(configToml.matchAll(/^\s*env_key\s*=\s*"([^"]+)"\s*$/gm), (match) => match[1]),
),
);
}
function loadCodexConfigEnvKeys(): string[] {
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
const configPath = join(codexHome, 'config.toml');
try {
return extractCodexConfigEnvKeys(readFileSync(configPath, 'utf-8'));
} catch {
return [];
}
}
export function filterCodexEnv(
env: Record<string, string | undefined>,
extraAllowedKeys: Iterable<string> = [],
): Record<string, string | undefined> {
const result: Record<string, string | undefined> = {};
const extraAllowed = new Set(extraAllowedKeys);
for (const [k, v] of Object.entries(env)) {
if (CODEX_ENV_ALLOWLIST.has(k) || k.startsWith('OPENAI_') || k.startsWith('CODEX_')) {
if (
CODEX_ENV_ALLOWLIST.has(k) ||
extraAllowed.has(k) ||
k.startsWith('OPENAI_') ||
k.startsWith('CODEX_')
) {
result[k] = v;
}
}
@ -140,7 +173,10 @@ export async function* streamCodexExec(
];
const child = spawn('codex', args, {
env: filterCodexEnv(process.env as Record<string, string | undefined>),
env: filterCodexEnv(
process.env as Record<string, string | undefined>,
loadCodexConfigEnvKeys(),
),
stdio: ['pipe', 'pipe', 'pipe'],
...(process.platform === 'win32' && { shell: true }),
});
@ -232,8 +268,9 @@ async function executeCodexCommand(
stdinText?: string,
): Promise<{ text: string; errors: string[] }> {
return await new Promise((resolve, reject) => {
const codexConfigEnvKeys = loadCodexConfigEnvKeys();
const child = spawn('codex', args, {
env: filterCodexEnv(process.env as Record<string, string | undefined>),
env: filterCodexEnv(process.env as Record<string, string | undefined>, codexConfigEnvKeys),
stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'],
// On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve.
...(process.platform === 'win32' && { shell: true }),

View file

@ -2,6 +2,7 @@ import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { serverLog } from './server-logger';
import { posixUserBinDirs, probeViaLoginShell } from './cli-resolver-helpers';
const isWindows = process.platform === 'win32';
@ -22,7 +23,9 @@ function resolveWinExtension(binPath: string): string {
/** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */
export function resolveCopilotCli(): string | undefined {
serverLog.info(`[resolve-copilot] platform=${process.platform}, isWindows=${isWindows}`);
serverLog.info(
`[resolve-copilot] platform=${process.platform}, isWindows=${isWindows}, SHELL=${process.env.SHELL ?? 'unset'}`,
);
// 1. Try PATH lookup
try {
@ -41,7 +44,13 @@ export function resolveCopilotCli(): string | undefined {
);
}
// 2. Try `npm prefix -g` on Windows (npm install -g creates .cmd wrappers)
// 2. macOS/Linux login-shell probe
if (!isWindows) {
const viaShell = probeViaLoginShell('copilot', 'resolve-copilot');
if (viaShell) return viaShell;
}
// 3. Try `npm prefix -g` on Windows (npm install -g creates .cmd wrappers)
if (isWindows) {
try {
serverLog.info('[resolve-copilot] trying npm.cmd prefix -g');
@ -63,25 +72,27 @@ export function resolveCopilotCli(): string | undefined {
}
}
// 3. Common install locations
if (isWindows) {
const candidates = [
// npm global (.cmd + .ps1)
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'copilot'),
// nvm-windows / fnm
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'copilot'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'copilot'),
// winget / native
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'copilot.exe'),
];
for (const c of candidates) {
const exists = c ? existsSync(c) : false;
serverLog.info(`[resolve-copilot] candidate: "${c}" (exists=${exists})`);
if (c && exists) return c;
}
// 4. Common install locations
const candidates = isWindows
? [
// npm global (.cmd + .ps1)
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'copilot'),
// nvm-windows / fnm
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'copilot'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'copilot'),
// winget / native
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'copilot.exe'),
]
: posixUserBinDirs().map((dir) => join(dir, 'copilot'));
for (const c of candidates) {
const exists = c ? existsSync(c) : false;
serverLog.info(`[resolve-copilot] candidate: "${c}" (exists=${exists})`);
if (c && exists) return c;
}
serverLog.warn('[resolve-copilot] no copilot binary found');
serverLog.warn(
'[resolve-copilot] no copilot binary found after PATH, login-shell probe, and candidate scan',
);
return undefined;
}

View file

@ -71,6 +71,7 @@ function broadcast(payload: Record<string, unknown>, excludeClientId?: string):
recipients.push(client);
}
// Return early when there are no recipients to avoid pointless JSON serialization for large documents.
if (recipients.length === 0) return;
const data = JSON.stringify(payload);

View file

@ -149,12 +149,6 @@ export function buildClaudeAgentEnv(): EnvLike {
merged.DEBUG_CLAUDE_AGENT_SDK = '1';
}
// Apply NODE_TLS_REJECT_UNAUTHORIZED to the current process as well,
// so Node.js HTTP/TLS in this process (used by the SDK internals) respects it.
if (merged.NODE_TLS_REJECT_UNAUTHORIZED && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = merged.NODE_TLS_REJECT_UNAUTHORIZED;
}
if (IS_WIN) {
// Redirect Claude debug output to temp to avoid write permission issues
if (!merged.CLAUDE_DEBUG_FILE) {
@ -187,37 +181,6 @@ export function buildClaudeAgentEnv(): EnvLike {
return merged;
}
/**
* Resolve the model to pass to Claude Code Agent SDK.
*
* When a custom ANTHROPIC_BASE_URL is set (proxy mode), the proxy may not
* recognize standard Claude model IDs like "claude-sonnet-4-6". Map the
* requested model tier to the proxy's real model via ANTHROPIC_DEFAULT_*_MODEL
* env vars (read from ~/.claude/settings.json).
*
* Example: user selects "Claude Sonnet 4.6" detected as sonnet tier
* mapped to ANTHROPIC_DEFAULT_SONNET_MODEL (e.g. "gpt-5.3-codex")
*/
export function resolveAgentModel(
requestedModel: string | undefined,
env: Record<string, string | undefined>,
): string | undefined {
if (!requestedModel) return undefined;
if (!env.ANTHROPIC_BASE_URL) return requestedModel;
// Proxy mode: map model tier to the proxy's model via env vars
const lower = requestedModel.toLowerCase();
if (lower.includes('opus'))
return env.ANTHROPIC_DEFAULT_OPUS_MODEL || env.ANTHROPIC_MODEL || undefined;
if (lower.includes('haiku'))
return env.ANTHROPIC_DEFAULT_HAIKU_MODEL || env.ANTHROPIC_MODEL || undefined;
if (lower.includes('sonnet'))
return env.ANTHROPIC_DEFAULT_SONNET_MODEL || env.ANTHROPIC_MODEL || undefined;
// Unknown tier: use the general default
return env.ANTHROPIC_MODEL || undefined;
}
/**
* Force Claude CLI debug output into a writable temp location.
* This avoids crashes in restricted environments where ~/.claude/debug is not writable.

View file

@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { join } from 'node:path';
import { serverLog } from './server-logger';
import { posixUserBinDirs, probeViaLoginShell } from './cli-resolver-helpers';
const isWindows = platform() === 'win32';
@ -31,7 +32,10 @@ function resolveWinExtension(binPath: string): string {
* binaries and spawns them directly (no `node` wrapper needed).
*/
export function resolveClaudeCli(): string | undefined {
serverLog.info(`[resolve-claude-cli] platform=${platform()}, isWindows=${isWindows}`);
serverLog.info(
`[resolve-claude-cli] platform=${platform()}, isWindows=${isWindows}, SHELL=${process.env.SHELL ?? 'unset'}`,
);
serverLog.info(`[resolve-claude-cli] PATH=${(process.env.PATH ?? '').slice(0, 400)}`);
// 1. Try PATH lookup
try {
@ -52,7 +56,16 @@ export function resolveClaudeCli(): string | undefined {
);
}
// 2. Try `npm prefix -g` to find actual npm global bin directory
// 2. On macOS/Linux, ask the user's login shell — Electron does not
// inherit the user's shell config, so nvm/pnpm/bun/mise/asdf shims are
// invisible to a bare `which`. This is the single most common cause of
// "CLI installed but OpenPencil can't find it" reports on Mac.
if (!isWindows) {
const viaShell = probeViaLoginShell('claude', 'resolve-claude-cli');
if (viaShell) return viaShell;
}
// 3. Try `npm prefix -g` to find actual npm global bin directory
// On Windows, must use `npm.cmd` since Electron spawns cmd.exe
if (isWindows) {
try {
@ -77,7 +90,7 @@ export function resolveClaudeCli(): string | undefined {
}
}
// 3. Common install locations
// 4. Common install locations
const candidates = isWindows
? [
// npm global (.cmd + .ps1)
@ -91,17 +104,15 @@ export function resolveClaudeCli(): string | undefined {
join(homedir(), '.claude', 'local', 'claude.exe'),
join(homedir(), 'AppData', 'Local', 'Programs', 'claude-code', 'claude.exe'),
]
: [
join(homedir(), '.local', 'bin', 'claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
];
: posixUserBinDirs().map((dir) => join(dir, 'claude'));
for (const c of candidates) {
const exists = c ? existsSync(c) : false;
serverLog.info(`[resolve-claude-cli] candidate: "${c}" (exists=${exists})`);
if (c && exists) return c;
}
serverLog.warn('[resolve-claude-cli] no claude binary found');
serverLog.warn(
'[resolve-claude-cli] no claude binary found after PATH, login-shell probe, and candidate scan',
);
return undefined;
}

View file

@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { serverLog } from './server-logger';
import { posixUserBinDirs, probeViaLoginShell } from './cli-resolver-helpers';
const isWindows = process.platform === 'win32';
@ -23,7 +24,9 @@ function resolveWinExtension(binPath: string): string {
/** Resolve the Gemini CLI binary path across macOS, Linux, and Windows. */
export function resolveGeminiCli(): string | undefined {
serverLog.info(`[resolve-gemini] platform=${process.platform}, isWindows=${isWindows}`);
serverLog.info(
`[resolve-gemini] platform=${process.platform}, isWindows=${isWindows}, SHELL=${process.env.SHELL ?? 'unset'}`,
);
// 1. Try PATH lookup
try {
@ -42,7 +45,13 @@ export function resolveGeminiCli(): string | undefined {
);
}
// 2. Try `npm prefix -g` (Windows uses npm.cmd; Unix uses npm)
// 2. macOS/Linux login-shell probe
if (!isWindows) {
const viaShell = probeViaLoginShell('gemini', 'resolve-gemini');
if (viaShell) return viaShell;
}
// 3. Try `npm prefix -g` (Windows uses npm.cmd; Unix uses npm)
try {
const npmCmd = isWindows ? 'npm.cmd prefix -g' : 'npm prefix -g';
serverLog.info(`[resolve-gemini] npm prefix lookup: ${npmCmd}`);
@ -66,7 +75,7 @@ export function resolveGeminiCli(): string | undefined {
);
}
// 3. Common install locations
// 4. Common install locations
const home = homedir();
const candidates = isWindows
? [
@ -79,13 +88,9 @@ export function resolveGeminiCli(): string | undefined {
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'gemini.exe'),
]
: [
// npm global
'/usr/local/bin/gemini',
// Homebrew (macOS)
'/opt/homebrew/bin/gemini',
// User-local
join(home, '.local', 'bin', 'gemini'),
// Explicit npm-global prefix some users set manually
join(home, '.npm-global', 'bin', 'gemini'),
...posixUserBinDirs().map((dir) => join(dir, 'gemini')),
];
for (const c of candidates) {
@ -94,6 +99,8 @@ export function resolveGeminiCli(): string | undefined {
if (c && exists) return c;
}
serverLog.warn('[resolve-gemini] no gemini binary found');
serverLog.warn(
'[resolve-gemini] no gemini binary found after PATH, login-shell probe, and candidate scan',
);
return undefined;
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import type { PenNode } from '@/types/pen';
import { shouldAutoReparentOnDragOutsideParent } from './drag-reparent-policy';
const node = (type: PenNode['type']): PenNode => ({ id: type, type }) as PenNode;
describe('shouldAutoReparentOnDragOutsideParent', () => {
it('keeps frame and shape-style nodes parented while dragging', () => {
expect(shouldAutoReparentOnDragOutsideParent(node('frame'))).toBe(false);
expect(shouldAutoReparentOnDragOutsideParent(node('group'))).toBe(false);
expect(shouldAutoReparentOnDragOutsideParent(node('rectangle'))).toBe(false);
expect(shouldAutoReparentOnDragOutsideParent(node('ellipse'))).toBe(false);
expect(shouldAutoReparentOnDragOutsideParent(node('line'))).toBe(false);
expect(shouldAutoReparentOnDragOutsideParent(node('polygon'))).toBe(false);
expect(shouldAutoReparentOnDragOutsideParent(node('path'))).toBe(false);
expect(shouldAutoReparentOnDragOutsideParent(node('ref'))).toBe(false);
});
it('still allows leaf content to detach with the legacy behavior', () => {
expect(shouldAutoReparentOnDragOutsideParent(node('text'))).toBe(true);
expect(shouldAutoReparentOnDragOutsideParent(node('image'))).toBe(true);
expect(shouldAutoReparentOnDragOutsideParent(node('icon_font'))).toBe(true);
});
it('falls back safely when node data is unavailable', () => {
expect(shouldAutoReparentOnDragOutsideParent(undefined)).toBe(true);
});
});

View file

@ -0,0 +1,25 @@
import type { PenNode } from '@/types/pen';
/**
* Auto-reparenting a dragged child out of its parent is surprising for
* frame/shape-style nodes, because users expect those nested objects to keep
* their parent while being repositioned. Primitive content nodes can still use
* the legacy "drag out to detach" behavior.
*/
export function shouldAutoReparentOnDragOutsideParent(node: PenNode | undefined): boolean {
if (!node) return true;
switch (node.type) {
case 'frame':
case 'group':
case 'rectangle':
case 'ellipse':
case 'line':
case 'polygon':
case 'path':
case 'ref':
return false;
default:
return true;
}
}

View file

@ -27,6 +27,7 @@ import {
hitTestPathControl,
} from './skia-hit-handlers';
import { bakeSceneAnchorsToPathNode, getEditablePathState, movePathControl } from './path-editing';
import { shouldAutoReparentOnDragOutsideParent } from './drag-reparent-policy';
export interface TextEditState {
nodeId: string;
@ -1132,6 +1133,7 @@ export class SkiaInteractionManager {
for (const orig of this.dragOrigPositions) {
const parent = docStore.getParentOf(orig.id);
const draggedNode = docStore.getNodeById(orig.id);
const draggedRN = engine.renderNodes.find((rn) => rn.node.id === orig.id);
const objBounds = draggedRN
? { x: draggedRN.absX, y: draggedRN.absY, w: draggedRN.absW, h: draggedRN.absH }
@ -1153,7 +1155,7 @@ export class SkiaInteractionManager {
objBounds.y + objBounds.h <= pBounds.y ||
objBounds.y >= pBounds.y + pBounds.h;
if (outside) {
if (outside && shouldAutoReparentOnDragOutsideParent(draggedNode)) {
docStore.updateNode(orig.id, { x: objBounds.x, y: objBounds.y } as Partial<PenNode>);
docStore.moveNode(orig.id, null, 0);
continue;

View file

@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PenNode } from '@/types/pen';
const selectedNode: PenNode = {
id: 'node-1',
type: 'rectangle',
x: 0,
y: 0,
width: 100,
height: 80,
};
vi.mock('@/stores/canvas-store', () => ({
useCanvasStore: (selector: (state: unknown) => unknown) =>
selector({
selection: { selectedIds: ['node-1'] },
activePageId: 'page-1',
}),
}));
vi.mock('@/stores/document-store', () => ({
useDocumentStore: (selector: (state: unknown) => unknown) =>
selector({
getNodeById: (id: string) => (id === 'node-1' ? selectedNode : undefined),
document: { variables: {} },
}),
getActivePageChildren: () => [selectedNode],
}));
vi.mock('@/stores/ai-store', () => ({
useAIStore: (selector: (state: unknown) => unknown) =>
selector({
model: 'test-model',
modelGroups: [{ provider: 'builtin', models: [{ value: 'test-model' }] }],
}),
}));
const generatedResult = {
code: 'export default function Design() { return null; }',
degraded: false,
assets: [
{
id: 'asset-1',
relativePath: './assets/hero-card-1.png',
zipPath: 'assets/hero-card-1.png',
mimeType: 'image/png',
bytes: new Uint8Array([1, 2, 3]),
sourceNodeId: 'node-1',
sourceNodeName: 'Hero Card',
sourceKind: 'image-fill' as const,
},
],
};
const generateCodeMock = vi.fn(async (args: unknown[]) => {
const onProgress = args[3] as ((event: Record<string, unknown>) => void) | undefined;
onProgress?.({
step: 'complete',
finalCode: generatedResult.code,
degraded: generatedResult.degraded,
});
return generatedResult;
});
vi.mock('@/services/ai/code-generation-pipeline', () => ({
generateCode: (...args: unknown[]) => generateCodeMock(args),
}));
vi.mock('@/services/ai/codegen-assets', () => ({
buildCodegenBundleManifest: vi.fn(async () => ({ version: 2, assets: [] })),
}));
vi.mock('@/services/ai/structure-bundle', () => ({
buildAIStructureBundle: vi.fn(async () => ({
fileName: 'ai-structure-bundle.zip',
zipEntries: {},
})),
encodeAIStructureBundleZip: vi.fn(() => new ArrayBuffer(0)),
}));
vi.mock('@/utils/syntax-highlight', () => ({
highlightCode: (code: string) => code,
}));
import CodePanel from './code-panel';
afterEach(() => {
cleanup();
});
describe('CodePanel export affordances', () => {
it('shows the AI bundle export action in the empty state', () => {
render(<CodePanel />);
expect(screen.getByRole('button', { name: /Export AI Bundle/i })).toBeTruthy();
});
it('shows AI bundle and zip download actions after generation with assets', async () => {
render(<CodePanel />);
fireEvent.click(screen.getAllByRole('button', { name: /Generate React/i })[0]);
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /AI Bundle/i }).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: /Download ZIP/i })).toBeTruthy();
});
});
});

View file

@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
import {
Copy,
Download,
FileJson,
RefreshCw,
Sparkles,
Check,
@ -16,11 +17,14 @@ import { useCanvasStore } from '@/stores/canvas-store';
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store';
import { useAIStore } from '@/stores/ai-store';
import { generateCode } from '@/services/ai/code-generation-pipeline';
import { buildCodegenBundleManifest, type CodegenAssetFile } from '@/services/ai/codegen-assets';
import { buildAIStructureBundle, encodeAIStructureBundleZip } from '@/services/ai/structure-bundle';
import { highlightCode } from '@/utils/syntax-highlight';
import type { Framework, CodeGenProgress, ChunkStatus } from '@zseven-w/pen-types';
import { FRAMEWORKS } from '@zseven-w/pen-types';
import type { PenNode } from '@/types/pen';
import type { SyntaxLanguage } from '@/utils/syntax-highlight';
import { encode as encodeZip } from 'uzip';
type PanelState = 'empty' | 'generating' | 'complete';
@ -31,6 +35,12 @@ interface ChunkProgress {
error?: string;
}
interface GeneratedCodeBundle {
code: string;
degraded: boolean;
assets: CodegenAssetFile[];
}
const TAB_LABELS: Record<Framework, string> = {
react: 'React',
vue: 'Vue',
@ -53,11 +63,20 @@ const HIGHLIGHT_LANG: Record<Framework, SyntaxLanguage> = {
'react-native': 'jsx',
};
function triggerDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const link = globalThis.document.createElement('a');
link.href = url;
link.download = fileName;
globalThis.document.body.appendChild(link);
link.click();
globalThis.document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function CodePanelInner() {
const [activeTab, setActiveTab] = useState<Framework>('react');
const [codeCache, setCodeCache] = useState<
Partial<Record<Framework, { code: string; degraded: boolean }>>
>({});
const [codeCache, setCodeCache] = useState<Partial<Record<Framework, GeneratedCodeBundle>>>({});
const [isDegraded, setIsDegraded] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
@ -74,6 +93,8 @@ function CodePanelInner() {
const cached = codeCache[activeTab];
const generatedCode = cached?.code ?? '';
const exportedAssets = cached?.assets ?? [];
const hasExportedAssets = exportedAssets.length > 0;
const panelState: PanelState = isGenerating ? 'generating' : cached ? 'complete' : 'empty';
const abortRef = useRef<AbortController | null>(null);
@ -162,10 +183,6 @@ function CodePanelInner() {
setAssemblyStatus(event.status);
break;
case 'complete':
setCodeCache((prev) => ({
...prev,
[activeTab]: { code: event.finalCode, degraded: event.degraded },
}));
setIsDegraded(event.degraded);
setIsGenerating(false);
break;
@ -177,7 +194,7 @@ function CodePanelInner() {
};
try {
await generateCode(
const result = await generateCode(
nodes,
activeTab,
variables,
@ -186,6 +203,15 @@ function CodePanelInner() {
provider,
abortRef.current.signal,
);
setCodeCache((prev) => ({
...prev,
[activeTab]: {
code: result.code,
degraded: result.degraded,
assets: result.assets,
},
}));
setIsDegraded(result.degraded);
} catch (err) {
if (!abortRef.current?.signal.aborted) {
const msg = err instanceof Error ? err.message : 'Code generation failed';
@ -226,14 +252,58 @@ function CodePanelInner() {
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 codeFileName = `design${extensions[activeTab]}`;
const assets = exportedAssets;
const codeBytes = new TextEncoder().encode(generatedCode);
void (async () => {
const blob =
assets.length > 0
? new Blob(
[
encodeZip({
[codeFileName]: codeBytes,
'manifest.json': new TextEncoder().encode(
JSON.stringify(
await buildCodegenBundleManifest({
framework: activeTab,
codeFile: codeFileName,
codeBytes,
assets,
}),
null,
2,
),
),
...Object.fromEntries(assets.map((asset) => [asset.zipPath, asset.bytes])),
}),
],
{ type: 'application/zip' },
)
: new Blob([generatedCode], { type: 'text/plain;charset=utf-8' });
triggerDownload(blob, assets.length > 0 ? `design-${activeTab}.zip` : codeFileName);
})();
}, [generatedCode, activeTab, exportedAssets]);
const handleDownloadStructureBundle = useCallback(() => {
const nodes = getTargetNodes();
if (nodes.length === 0) return;
void (async () => {
const bundle = await buildAIStructureBundle({
nodes,
activePageId,
selectedIds,
});
const blob = new Blob([encodeAIStructureBundleZip(bundle.zipEntries)], {
type: 'application/zip',
});
triggerDownload(blob, bundle.fileName);
})();
}, [getTargetNodes, activePageId, selectedIds]);
const handleTabChange = useCallback(
(tab: Framework) => {
@ -342,6 +412,16 @@ function CodePanelInner() {
<Sparkles className="h-3.5 w-3.5" />
Generate {TAB_LABELS[activeTab]}
</Button>
<Button
variant="ghost"
onClick={() => void handleDownloadStructureBundle()}
disabled={nodeCount === 0}
size="sm"
className="h-8 gap-1.5 text-xs"
>
<FileJson className="h-3.5 w-3.5" />
Export AI Bundle
</Button>
{generateError && (
<div className="max-w-[260px] rounded-lg border border-destructive/20 bg-destructive/8 px-3 py-2 text-xs text-destructive">
<div className="font-medium">Generation failed</div>
@ -448,6 +528,20 @@ function CodePanelInner() {
</button>
</div>
)}
{hasExportedAssets && (
<div className="border-b border-border/50 bg-muted/40 px-3 py-2 shrink-0">
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
<Download className="h-3.5 w-3.5 shrink-0" />
This generation includes {exportedAssets.length} image asset
{exportedAssets.length > 1 ? 's' : ''}. Download will export a ZIP bundle.
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
The ZIP contains the code file, exported assets, and a
<code className="font-mono"> manifest.json </code>
index.
</div>
</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 }} />
@ -478,7 +572,17 @@ function CodePanelInner() {
onClick={handleDownload}
>
<Download className="mr-1 h-3 w-3 shrink-0" />
<span className="truncate">Download</span>
<span className="truncate">{hasExportedAssets ? 'Download ZIP' : 'Download'}</span>
</Button>
<div className="w-px h-4 bg-border/50" />
<Button
variant="ghost"
size="sm"
className="h-7 flex-1 px-1 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => void handleDownloadStructureBundle()}
>
<FileJson className="mr-1 h-3 w-3 shrink-0" />
<span className="truncate">AI Bundle</span>
</Button>
<div className="w-px h-4 bg-border/50" />
<Button

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import type { PenNode } from '@/types/pen';
import { resolveLayerDropMove } from './layer-dnd-utils';
const frame = (id: string, children: PenNode[] = []): PenNode => ({
id,
type: 'frame',
x: 0,
y: 0,
width: 100,
height: 100,
children,
});
const rect = (id: string): PenNode => ({
id,
type: 'rectangle',
x: 0,
y: 0,
width: 10,
height: 10,
});
function findParent(nodes: PenNode[], id: string): PenNode | undefined {
for (const node of nodes) {
if ('children' in node && node.children?.some((child) => child.id === id)) return node;
if ('children' in node && node.children) {
const nested = findParent(node.children, id);
if (nested) return nested;
}
}
return undefined;
}
describe('resolveLayerDropMove', () => {
it('preserves absolute position when moving a nested node to root', () => {
const tree = [frame('parent', [rect('child')]), rect('root-sibling')];
expect(
resolveLayerDropMove('child', 'root-sibling', 'above', tree, (id) => findParent(tree, id)),
).toEqual({
parentId: null,
index: 1,
preserveAbsolutePosition: true,
});
});
it('does not preserve absolute position for same-parent reorder', () => {
const tree = [frame('parent', [rect('a'), rect('b')])];
expect(resolveLayerDropMove('a', 'b', 'below', tree, (id) => findParent(tree, id))).toEqual({
parentId: 'parent',
index: 2,
preserveAbsolutePosition: false,
});
});
it('preserves absolute position when moving into a different container', () => {
const tree = [frame('source', [rect('child')]), frame('target')];
expect(
resolveLayerDropMove('child', 'target', 'inside', tree, (id) => findParent(tree, id)),
).toEqual({
parentId: 'target',
index: 0,
preserveAbsolutePosition: true,
});
});
});

View file

@ -0,0 +1,42 @@
import type { PenNode } from '@/types/pen';
import type { DropPosition } from './layer-item';
export interface LayerDropMove {
parentId: string | null;
index: number;
preserveAbsolutePosition: boolean;
}
/**
* Resolve the destination for a layer-panel drag/drop and whether the move
* needs absolute-position preservation because it crosses parent boundaries.
*/
export function resolveLayerDropMove(
dragId: string,
overId: string,
pos: Exclude<DropPosition, null>,
rootChildren: PenNode[],
getParentOf: (id: string) => PenNode | undefined,
): LayerDropMove | null {
const currentParentId = getParentOf(dragId)?.id ?? null;
if (pos === 'inside') {
return {
parentId: overId,
index: 0,
preserveAbsolutePosition: currentParentId !== overId,
};
}
const parent = getParentOf(overId);
const parentId = parent?.id ?? null;
const siblings = parent ? ('children' in parent ? (parent.children ?? []) : []) : rootChildren;
const targetIdx = siblings.findIndex((n) => n.id === overId);
if (targetIdx === -1) return null;
return {
parentId,
index: pos === 'above' ? targetIdx : targetIdx + 1,
preserveAbsolutePosition: currentParentId !== parentId,
};
}

View file

@ -9,8 +9,9 @@ import LayerItem from './layer-item';
import type { DropPosition } from './layer-item';
import LayerContextMenu from './layer-context-menu';
import PageTabs from '@/components/editor/page-tabs';
import { resolveLayerDropMove } from './layer-dnd-utils';
const CONTAINER_TYPES = new Set(['frame', 'group', 'ref']);
const CONTAINER_TYPES = new Set(['frame', 'group', 'rectangle', 'ref']);
const LAYER_MIN_WIDTH = 180;
const LAYER_MAX_WIDTH = 480;
@ -322,22 +323,28 @@ function LayerPanelInner() {
const handleDragEnd = useCallback(() => {
const { dragId, overId, dropPosition: pos } = dragRef.current;
if (dragId && overId && dragId !== overId && pos) {
const parent = getParentOf(overId);
const parentId = parent ? parent.id : null;
const siblings = parent ? ('children' in parent ? (parent.children ?? []) : []) : children;
const targetIdx = siblings.findIndex((n) => n.id === overId);
const move = resolveLayerDropMove(dragId, overId, pos, children, getParentOf);
if (pos === 'inside') {
moveNode(dragId, overId, 0);
if (move && pos === 'inside') {
moveNode(
dragId,
move.parentId,
move.index,
move.preserveAbsolutePosition ? { preserveAbsolutePosition: true } : undefined,
);
// Auto-expand the target so the dropped item is visible
setCollapsedIds((prev) => {
const next = new Set(prev);
next.delete(overId);
return next;
});
} else if (targetIdx !== -1) {
const insertIdx = pos === 'above' ? targetIdx : targetIdx + 1;
moveNode(dragId, parentId, insertIdx);
} else if (move) {
moveNode(
dragId,
move.parentId,
move.index,
move.preserveAbsolutePosition ? { preserveAbsolutePosition: true } : undefined,
);
}
}
dragRef.current = { dragId: null, overId: null, dropPosition: null };

View file

@ -48,6 +48,7 @@ const PRESET_ORDER: BuiltinProviderPreset[] = [
'xiaomi',
'modelscope',
'stepfun',
'stepfun-coding',
'nvidia',
'custom',
];

View file

@ -202,6 +202,17 @@ export const BUILTIN_PROVIDER_PRESETS: Record<BuiltinProviderPreset, BuiltinPres
global: { baseURL: 'https://api.stepfun.ai/v1' },
},
},
'stepfun-coding': {
label: 'StepFun Coding Plan',
type: 'openai-compat',
baseURL: 'https://api.stepfun.com/step_plan/v1',
placeholder: 'API Key',
modelPlaceholder: 'step-3-coding',
regions: {
cn: { baseURL: 'https://api.stepfun.com/step_plan/v1' },
global: { baseURL: 'https://api.stepfun.ai/step_plan/v1' },
},
},
nvidia: {
label: 'NVIDIA NIM',
type: 'openai-compat',

View file

@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildCodegenBundleManifest,
collectChunkAssetHints,
extractCodegenAssets,
} from '../codegen-assets';
describe('codegen-assets', () => {
it('extracts image fill data urls into exported asset files', () => {
const dataUrl = `data:image/png;base64,${'a'.repeat(64)}`;
const { nodes, assets } = extractCodegenAssets([
{
id: 'node-1',
type: 'rectangle',
name: 'Hero Card',
x: 0.05,
y: -0.39,
width: 2560,
height: 1600,
fill: [
{
type: 'image',
url: dataUrl,
mode: 'stretch',
transform: {
m00: 0.9682299494743347,
m01: 0,
m02: 0.019307976588606834,
m10: 0,
m11: 0.9433962106704712,
m12: 0.041042111814022064,
},
},
],
} as any,
]);
expect(assets).toHaveLength(1);
expect(assets[0].relativePath).toMatch(/^\.\/assets\/hero-card-1\.png$/);
expect((nodes[0] as any).fill[0].url).toBe(assets[0].relativePath);
expect((nodes[0] as any).fill[0].originalSize).toEqual({
width: 2644,
height: 1696,
});
expect((nodes[0] as any).fill[0].explain).toBe(
'This is not a full-image stretch; the source image is cropped before being mapped into the target bounds',
);
});
it('extracts image node src and deduplicates identical data urls', () => {
const dataUrl = `data:image/jpeg;base64,${'b'.repeat(64)}`;
const { nodes, assets } = extractCodegenAssets([
{
id: 'img-1',
type: 'image',
name: 'Preview',
x: 0,
y: 0,
width: 100,
height: 100,
src: dataUrl,
} as any,
{
id: 'shape-1',
type: 'rectangle',
name: 'Card',
x: 0,
y: 0,
width: 100,
height: 100,
fill: [{ type: 'image', url: dataUrl, mode: 'fill' }],
} as any,
]);
expect(assets).toHaveLength(1);
expect((nodes[0] as any).src).toBe(assets[0].relativePath);
expect((nodes[1] as any).fill[0].url).toBe(assets[0].relativePath);
});
it('collects chunk-local asset hints for prompt building', () => {
const dataUrl = `data:image/png;base64,${'c'.repeat(64)}`;
const { nodes, assets } = extractCodegenAssets([
{
id: 'node-1',
type: 'rectangle',
name: 'Gallery',
x: 0,
y: 0,
width: 300,
height: 200,
fill: [{ type: 'image', url: dataUrl, mode: 'crop' }],
} as any,
]);
const hints = collectChunkAssetHints(nodes as any, assets);
expect(hints).toHaveLength(1);
expect(hints[0].relativePath).toBe(assets[0].relativePath);
expect(hints[0].sourceKind).toBe('image-fill');
});
it('builds a stable manifest for bundle export', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-09T13:07:40.000Z'));
const dataUrl = `data:image/png;base64,${'d'.repeat(64)}`;
const { assets } = extractCodegenAssets([
{
id: 'node-1',
type: 'rectangle',
name: 'Poster',
x: 0,
y: 0,
width: 300,
height: 200,
fill: [{ type: 'image', url: dataUrl, mode: 'crop' }],
} as any,
]);
const manifest = await buildCodegenBundleManifest({
framework: 'react',
codeFile: 'design.tsx',
codeBytes: new TextEncoder().encode('export default function Design() { return null }'),
assets,
});
expect(manifest.version).toBe(2);
expect(manifest.framework).toBe('react');
expect(manifest.entry.codeFile).toBe('design.tsx');
expect(manifest.generatedAt).toBe('2026-04-09T13:07:40.000Z');
expect(manifest.code.path).toBe('design.tsx');
expect(manifest.code.size).toBeGreaterThan(10);
expect(manifest.code.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(manifest.assets).toHaveLength(1);
expect(manifest.assets[0].relativePath).toBe(assets[0].relativePath);
expect(manifest.assets[0].size).toBe(48);
expect(manifest.assets[0].sha256).toMatch(/^[0-9a-f]{64}$/);
vi.useRealTimers();
});
});

View file

@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import { enrichNodeForAIConsumerView } from '../consumer-view-enrichment';
describe('consumer-view-enrichment', () => {
it('adds image fill explain and keeps existing original size as the source of truth', () => {
const enriched = enrichNodeForAIConsumerView({
id: 'node-1',
type: 'rectangle',
name: 'Background 11',
x: 0.05,
y: -0.39,
width: 2560,
height: 1600,
fill: [
{
type: 'image',
url: './assets/11-33.png',
mode: 'stretch',
originalSize: {
width: 2644,
height: 1696,
},
transform: {
m00: 0.9682299494743347,
m01: 0,
m02: 0.019307976588606834,
m10: 0,
m11: 0.9433962106704712,
m12: 0.041042111814022064,
},
},
],
} as any);
expect((enriched as any).fill[0]).toEqual({
type: 'image',
url: './assets/11-33.png',
mode: 'stretch',
originalSize: {
width: 2644,
height: 1696,
},
transform: {
m00: 0.9682299494743347,
m01: 0,
m02: 0.019307976588606834,
m10: 0,
m11: 0.9433962106704712,
m12: 0.041042111814022064,
},
explain:
'This is not a full-image stretch; the source image is cropped before being mapped into the target bounds',
});
});
it('can still infer original size from axis-aligned transform when upstream size is missing', () => {
const enriched = enrichNodeForAIConsumerView({
id: 'node-2',
type: 'rectangle',
name: 'Poster',
width: 2560,
height: 1600,
fill: [
{
type: 'image',
url: './assets/poster.png',
mode: 'stretch',
transform: {
m00: 0.9682299494743347,
m01: 0,
m02: 0.019307976588606834,
m10: 0,
m11: 0.9433962106704712,
m12: 0.041042111814022064,
},
},
],
} as any);
expect((enriched as any).fill[0].originalSize).toEqual({
width: 2644,
height: 1696,
});
expect((enriched as any).fill[0].explain).toBe(
'This is not a full-image stretch; the source image is cropped before being mapped into the target bounds',
);
});
it('adds explain for gradients, auto-layout, clipContent, and image node objectFit', () => {
const enriched = enrichNodeForAIConsumerView({
id: 'frame-1',
type: 'frame',
name: 'Hero',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
gap: 24,
padding: [32, 24],
justifyContent: 'space_between',
alignItems: 'center',
clipContent: true,
fill: [
{
type: 'linear_gradient',
angle: 135,
stops: [
{ offset: 0, color: '#111111' },
{ offset: 1, color: '#999999' },
],
},
],
children: [
{
id: 'image-1',
type: 'image',
name: 'Hero Image',
src: './assets/hero.png',
objectFit: 'crop',
width: 320,
height: 180,
},
],
} as any);
expect((enriched as any).fill[0].explain).toBe(
'This is a linear gradient fill angled at 135deg with 2 color stops, so colors transition smoothly along that direction',
);
expect((enriched as any).explain).toBe(
'This is a horizontal auto-layout container, Child gap is 24, Container padding is 32 24, Main-axis alignment is space between, Cross-axis alignment is center aligned. Width stretches to fill the available space in the parent container, Height grows automatically with its content. This container clips children that overflow its bounds',
);
expect((enriched as any).children[0].explain).toBe(
'This is an image node. objectFit=crop uses cover to fill the container and may crop the edges. Width is fixed at 320px, Height is fixed at 180px',
);
});
it('describes sizingBehavior hints such as fill_container(300) and fit_content(120)', () => {
const enriched = enrichNodeForAIConsumerView({
id: 'node-3',
type: 'frame',
width: 'fill_container(300)',
height: 'fit_content(120)',
} as any);
expect((enriched as any).explain).toBe(
'Width stretches to fill the available space in the parent container, with a suggested value of about 300px, Height grows automatically with its content, with a suggested value of about 120px',
);
});
it('adds explain for effects and reusable/component-instance semantics', () => {
const reusable = enrichNodeForAIConsumerView({
id: 'component-1',
type: 'frame',
name: 'Card Component',
reusable: true,
slot: ['media', 'actions'],
effects: [
{
type: 'shadow',
offsetX: 0,
offsetY: 4,
blur: 12,
spread: -2,
color: 'rgba(0,0,0,0.12)',
},
],
} as any);
expect((reusable as any).explain).toBe(
'Has shadow with offset 0px 4px, blur 12px, spread -2px. This is a reusable component definition node that other instances can reference. It declares slot regions: media, actions',
);
const instance = enrichNodeForAIConsumerView({
id: 'instance-1',
type: 'ref',
ref: 'component-1',
descendants: {
'child-1': { visible: false },
'child-2': { opacity: 0.5 },
},
} as any);
expect((instance as any).explain).toBe(
'This is a component instance node referencing source node component-1. This instance overrides 2 descendant nodes',
);
});
it('adds explain for textGrowth, lineHeight, and text alignment semantics', () => {
const textNode = enrichNodeForAIConsumerView({
id: 'text-hero',
type: 'text',
content: 'Hello world',
width: 'fill_container',
textGrowth: 'fixed-width',
lineHeight: 1.5,
textAlign: 'center',
textAlignVertical: 'middle',
} as any);
expect((textNode as any).explain).toBe(
'This is a text node. textGrowth=fixed-width wraps text to the current width and grows vertically with the content. Line-height multiplier is 1.5. Horizontal alignment is center. Vertical alignment is middle. Width stretches to fill the available space in the parent container',
);
});
it('adds explain for variable refs and theme overrides', () => {
const themed = enrichNodeForAIConsumerView({
id: 'node-theme',
type: 'frame',
opacity: '$opacity-soft',
theme: {
ColorScheme: 'Dark',
Density: 'Compact',
},
fill: [{ type: 'solid', color: '$surface-bg' }],
stroke: {
thickness: '$border-width',
fill: [{ type: 'solid', color: '$border-color' }],
},
effects: [
{
type: 'shadow',
offsetX: '$shadow-x',
offsetY: '$shadow-y',
blur: '$shadow-blur',
spread: '$shadow-spread',
color: '$shadow-color',
},
],
} as any);
expect((themed as any).explain).toBe(
'Has shadow effect. opacity uses design token $opacity-soft. fill color uses design token $surface-bg. stroke thickness uses design token $border-width. stroke color uses design token $border-color. shadow color uses design token $shadow-color. shadow blur radius uses design token $shadow-blur. shadow X offset uses design token $shadow-x. shadow Y offset uses design token $shadow-y. shadow spread uses design token $shadow-spread. These values come from design-system tokens rather than hard-coded constants. This node carries theme override context: ColorScheme=Dark, Density=Compact',
);
});
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_MAX_CHARS,
estimateChatPayloadChars,
formatChatPayloadTooLargeError,
MAX_CHAT_REQUEST_CHARS,
trimChatHistory,
} from '../context-optimizer';
describe('context-optimizer', () => {
it('removes attachments from historical messages but keeps latest attachment message', () => {
const messages = [
{
role: 'user',
content: 'first image',
attachments: [{ name: 'old.png', mediaType: 'image/png', data: 'a'.repeat(1000) }],
},
{
role: 'assistant',
content: 'looks good',
},
{
role: 'user',
content: 'second image',
attachments: [{ name: 'new.png', mediaType: 'image/png', data: 'b'.repeat(2000) }],
},
];
const trimmed = trimChatHistory(messages);
expect(trimmed[0]).not.toHaveProperty('attachments');
expect(trimmed[2].attachments?.[0]?.data.length).toBe(2000);
});
it('still truncates oversized text content to the configured char limit', () => {
const trimmed = trimChatHistory([
{
role: 'user',
content: 'x'.repeat(DEFAULT_MAX_CHARS + 1000),
attachments: [{ name: 'big.png', mediaType: 'image/png', data: 'b'.repeat(5000) }],
},
]);
expect(trimmed).toHaveLength(1);
expect(trimmed[0].content.length).toBeLessThanOrEqual(
DEFAULT_MAX_CHARS + '[...truncated...]'.length + 2,
);
expect(trimmed[0].attachments?.[0]?.data.length).toBe(5000);
});
it('estimates payload size from serialized chat body', () => {
const payloadChars = estimateChatPayloadChars({
system: 'system',
messages: [
{
role: 'user',
content: 'hello',
attachments: [{ name: 'x.png', mediaType: 'image/png', data: 'c'.repeat(4000) }],
},
],
});
expect(payloadChars).toBeGreaterThan(4000);
});
it('formats actionable payload-too-large error text', () => {
const message = formatChatPayloadTooLargeError(MAX_CHAT_REQUEST_CHARS + 12345);
expect(message).toContain('AI input is too large');
expect(message).toContain('start a new chat');
});
});

View file

@ -0,0 +1,192 @@
import { describe, expect, it, vi } from 'vitest';
import { parse as parseZip } from 'uzip';
import {
buildAIStructureBundle,
encodeAIStructureBundleZip,
type AIStructureBundleManifest,
type AIStructureBundleViewFile,
} from '../structure-bundle';
describe('structure-bundle', () => {
it('builds raw and sanitized views with traceable asset refs', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-11T10:00:00.000Z'));
const dataUrl = `data:image/png;base64,${'e'.repeat(64)}`;
const bundle = await buildAIStructureBundle({
nodes: [
{
id: 'node-1',
type: 'rectangle',
name: 'Hero Card',
x: 0.05,
y: -0.39,
width: 2560,
height: 1600,
fill: [
{
type: 'image',
url: dataUrl,
mode: 'stretch',
transform: {
m00: 0.9682299494743347,
m01: 0,
m02: 0.019307976588606834,
m10: 0,
m11: 0.9433962106704712,
m12: 0.041042111814022064,
},
},
],
} as any,
],
activePageId: 'page-1',
selectedIds: ['node-1'],
});
expect(bundle.fileName).toBe('ai-structure-bundle.zip');
expect(bundle.manifest.kind).toBe('ai-structure-bundle');
expect(bundle.manifest.version).toBe(1);
expect(bundle.manifest.consumerView).toBe('sanitized');
expect(bundle.manifest.generatedAt).toBe('2026-04-11T10:00:00.000Z');
expect(bundle.manifest.scope.mode).toBe('selection');
expect(bundle.manifest.scope.activePageId).toBe('page-1');
expect(bundle.manifest.scope.selectedIds).toEqual(['node-1']);
expect(bundle.manifest.scope.exportedRootIds).toEqual(['node-1']);
expect(bundle.manifest.scope.exportedNodeCount).toBe(1);
expect(bundle.rawView.view).toBe('raw');
expect(bundle.rawView.consumer).toBe(false);
expect((bundle.rawView.nodes[0] as any).fill[0].url).toBe('asset://asset-1');
expect(bundle.sanitizedView.view).toBe('sanitized');
expect(bundle.sanitizedView.consumer).toBe(true);
expect(bundle.sanitizedView.summary).toContain(
'This is the sanitized structural view intended for direct AI consumption',
);
expect(bundle.sanitizedView.highlights).toContain('Includes image crop/mapping semantics');
expect((bundle.sanitizedView.nodes[0] as any).fill[0].url).toBe('./assets/hero-card-1.png');
expect((bundle.sanitizedView.nodes[0] as any).fill[0].originalSize).toEqual({
width: 2644,
height: 1696,
});
expect((bundle.sanitizedView.nodes[0] as any).fill[0].explain).toBe(
'This is not a full-image stretch; the source image is cropped before being mapped into the target bounds',
);
expect(bundle.manifest.assets).toHaveLength(1);
expect(bundle.manifest.assets[0]).toMatchObject({
id: 'asset-1',
relativePath: './assets/hero-card-1.png',
zipPath: 'assets/hero-card-1.png',
sourceNodeId: 'node-1',
sourceNodeName: 'Hero Card',
sourceKind: 'image-fill',
});
expect(bundle.manifest.assets[0].sha256).toMatch(/^[0-9a-f]{64}$/);
expect(bundle.manifest.assets[0].rawRefs).toEqual([
{
pointer: '#/nodes/0/fill/0/url',
nodeId: 'node-1',
field: 'fill.url',
value: 'asset://asset-1',
},
]);
expect(bundle.manifest.assets[0].sanitizedRefs).toEqual([
{
pointer: '#/nodes/0/fill/0/url',
nodeId: 'node-1',
field: 'fill.url',
value: './assets/hero-card-1.png',
},
]);
vi.useRealTimers();
});
it('encodes a zip with manifest, raw view, sanitized view, and assets only', async () => {
const dataUrl = `data:image/jpeg;base64,${'f'.repeat(64)}`;
const bundle = await buildAIStructureBundle({
nodes: [
{
id: 'img-1',
type: 'image',
name: 'Preview',
x: 0,
y: 0,
width: 120,
height: 120,
src: dataUrl,
} as any,
],
activePageId: 'page-2',
selectedIds: [],
});
const archive = parseZip(encodeAIStructureBundleZip(bundle.zipEntries));
const entryNames = Object.keys(archive).sort();
expect(entryNames).toEqual([
'assets/preview-1.jpg',
'manifest.json',
'views/raw.json',
'views/sanitized.json',
]);
expect(
entryNames.some(
(name) => name.endsWith('.tsx') || name.endsWith('.vue') || name.endsWith('.html'),
),
).toBe(false);
const manifest = JSON.parse(
new TextDecoder().decode(archive['manifest.json']),
) as AIStructureBundleManifest;
const rawView = JSON.parse(
new TextDecoder().decode(archive['views/raw.json']),
) as AIStructureBundleViewFile;
const sanitizedView = JSON.parse(
new TextDecoder().decode(archive['views/sanitized.json']),
) as AIStructureBundleViewFile;
expect(manifest.scope.mode).toBe('page');
expect(manifest.views.raw.path).toBe('views/raw.json');
expect(manifest.views.sanitized.path).toBe('views/sanitized.json');
expect(rawView.view).toBe('raw');
expect(sanitizedView.view).toBe('sanitized');
expect((rawView.nodes[0] as any).src).toBe('asset://asset-1');
expect((sanitizedView.nodes[0] as any).src).toBe('./assets/preview-1.jpg');
});
it('still exports a zip when there are no image assets', async () => {
const bundle = await buildAIStructureBundle({
nodes: [
{
id: 'text-1',
type: 'text',
name: 'Heading',
x: 10,
y: 20,
width: 200,
height: 40,
text: 'Hello',
} as any,
],
activePageId: null,
selectedIds: [],
});
const archive = parseZip(encodeAIStructureBundleZip(bundle.zipEntries));
const entryNames = Object.keys(archive).sort();
expect(entryNames).toEqual(['manifest.json', 'views/raw.json', 'views/sanitized.json']);
expect(bundle.manifest.assets).toEqual([]);
expect(bundle.sanitizedView.summary).toContain('containing 1 nodes');
expect(bundle.sanitizedView.highlights).toEqual([
'Primarily basic geometry and style structure',
]);
expect((bundle.rawView.nodes[0] as any).explain).toBeUndefined();
expect((bundle.sanitizedView.nodes[0] as any).explain).toBe(
'Width is fixed at 200px, Height is fixed at 40px',
);
});
});

View file

@ -6,6 +6,11 @@ import {
DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS,
STREAM_TIMEOUT_MIN_MS,
} from './ai-runtime-config';
import {
estimateChatPayloadChars,
formatChatPayloadTooLargeError,
MAX_CHAT_REQUEST_CHARS,
} from './context-optimizer';
interface StreamChatOptions {
hardTimeoutMs?: number;
@ -137,23 +142,37 @@ export async function* streamChat(
}
}
const requestPayload = {
system: systemPrompt,
messages: messages.map((m) => ({
role: m.role,
content: m.content,
...(m.attachments?.length ? { attachments: m.attachments } : {}),
})),
model,
provider,
thinkingMode: options?.thinkingMode,
thinkingBudgetTokens: options?.thinkingBudgetTokens,
effort: options?.effort,
...builtinFields,
};
const payloadChars = estimateChatPayloadChars(requestPayload);
if (payloadChars > MAX_CHAT_REQUEST_CHARS) {
yield {
type: 'error',
content: formatChatPayloadTooLargeError(payloadChars),
};
clearTimeout(hardTimeout);
clearNoTextTimeout();
clearFirstTextTimeout();
return;
}
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system: systemPrompt,
messages: messages.map((m) => ({
role: m.role,
content: m.content,
...(m.attachments?.length ? { attachments: m.attachments } : {}),
})),
model,
provider,
thinkingMode: options?.thinkingMode,
thinkingBudgetTokens: options?.thinkingBudgetTokens,
effort: options?.effort,
...builtinFields,
}),
body: JSON.stringify(requestPayload),
signal: fetchSignal,
});
@ -263,6 +282,10 @@ export async function* streamChat(
chunk.content.trim().length > 0 &&
thinkingResetsTimeout
) {
// Active reasoning is progress — the model isn't silent, so the
// "no first text yet" watchdog should stand down. noTextTimeout
// + hardTimeout still bound genuinely runaway reasoning.
clearFirstTextTimeout();
resetActivityTimeout();
}

View file

@ -16,6 +16,12 @@ import type {
import { sanitizeName } from '@zseven-w/pen-core';
import { buildPlanningPrompt, buildChunkPrompt, buildAssemblyPrompt } from './codegen-prompts';
import { streamChat } from './ai-service';
import {
collectChunkAssetHints,
extractCodegenAssets,
type CodegenAssetFile,
type CodegenAssetHint,
} from './codegen-assets';
// Inlined to avoid importing from @zseven-w/pen-mcp, which transitively pulls
// node:fs/promises via document-manager and breaks Vite browser builds.
@ -223,19 +229,20 @@ export async function generateCode(
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
): Promise<string> {
): Promise<{ code: string; degraded: boolean; assets: CodegenAssetFile[] }> {
const { nodes: sanitizedNodes, assets } = extractCodegenAssets(nodes);
// ── Step 1: Planning ──
onProgress({ step: 'planning', status: 'running' });
let planFromAI: CodePlanFromAI;
try {
planFromAI = await runPlanning(nodes, framework, model, provider, abortSignal);
planFromAI = await runPlanning(sanitizedNodes, 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);
planFromAI = await runPlanning(sanitizedNodes, framework, model, provider, abortSignal, true);
onProgress({ step: 'planning', status: 'done', plan: planFromAI });
} catch (retryErr) {
const msg = retryErr instanceof Error ? retryErr.message : 'Planning failed';
@ -246,7 +253,7 @@ export async function generateCode(
}
// Hydrate plan with actual node data
const execPlan = hydratePlan(planFromAI, nodes);
const execPlan = hydratePlan(planFromAI, sanitizedNodes);
if (execPlan.chunks.length === 0) {
const msg = 'Planning produced no valid chunks';
onProgress({ step: 'planning', status: 'failed', error: msg });
@ -287,6 +294,7 @@ export async function generateCode(
onProgress({ step: 'chunk', chunkId: chunk.id, name: chunk.name, status: 'running' });
try {
const assetHints = collectChunkAssetHints(chunk.nodes, assets);
const result = await runChunkGeneration(
chunk.nodes,
framework,
@ -296,6 +304,7 @@ export async function generateCode(
model,
provider,
abortSignal,
assetHints,
);
// Ensure componentName is valid PascalCase — AI may return kebab-case or empty
@ -327,11 +336,13 @@ export async function generateCode(
name: chunk.name,
status: 'degraded',
result,
error: validation.issues.join('; ') || 'Chunk contract validation failed',
});
}
} catch (err) {
// Retry once
try {
const assetHints = collectChunkAssetHints(chunk.nodes, assets);
const result = await runChunkGeneration(
chunk.nodes,
framework,
@ -341,6 +352,7 @@ export async function generateCode(
model,
provider,
abortSignal,
assetHints,
);
if (
!result.contract.componentName ||
@ -357,6 +369,9 @@ export async function generateCode(
name: chunk.name,
status: statuses.get(chunk.id)!,
result,
...(validation.valid
? {}
: { error: validation.issues.join('; ') || 'Chunk contract validation failed' }),
});
} catch (retryErr) {
statuses.set(chunk.id, 'failed');
@ -403,6 +418,7 @@ export async function generateCode(
let finalCode: string;
let degraded = chunkInputs.some((c) => c.status !== 'successful');
const exportedAssetPaths = assets.map((asset) => asset.relativePath);
try {
finalCode = await runAssembly(
@ -413,6 +429,7 @@ export async function generateCode(
model,
provider,
abortSignal,
exportedAssetPaths,
);
onProgress({ step: 'assembly', status: 'done' });
} catch {
@ -426,6 +443,7 @@ export async function generateCode(
model,
provider,
abortSignal,
exportedAssetPaths,
);
onProgress({ step: 'assembly', status: 'done' });
} catch {
@ -444,7 +462,7 @@ export async function generateCode(
}
onProgress({ step: 'complete', finalCode, degraded });
return finalCode;
return { code: finalCode, degraded, assets };
}
// ── Internal AI call wrappers ──
@ -516,8 +534,15 @@ async function runChunkGeneration(
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
assetHints: CodegenAssetHint[] = [],
): Promise<ChunkResult> {
const { system, user } = buildChunkPrompt(nodes, framework, suggestedComponentName, depContracts);
const { system, user } = buildChunkPrompt(
nodes,
framework,
suggestedComponentName,
depContracts,
assetHints,
);
const fullResponse = await collectStreamText(system, user, model, provider, abortSignal);
@ -538,8 +563,15 @@ async function runAssembly(
model: string,
provider: string | undefined,
abortSignal?: AbortSignal,
exportedAssetPaths: string[] = [],
): Promise<string> {
const { system, user } = buildAssemblyPrompt(chunkResults, plan, framework, variables);
const { system, user } = buildAssemblyPrompt(
chunkResults,
plan,
framework,
variables,
exportedAssetPaths,
);
const fullResponse = await collectStreamText(system, user, model, provider, abortSignal);

View file

@ -0,0 +1,268 @@
import type { PenNode } from '@/types/pen';
import type { PenFill } from '@/types/styles';
import { enrichNodeLocallyForAIConsumerView } from './consumer-view-enrichment';
export interface CodegenAssetFile {
id: string;
relativePath: string;
zipPath: string;
mimeType: string;
bytes: Uint8Array;
sourceNodeId: string;
sourceNodeName?: string;
sourceKind: 'image-node' | 'image-fill';
}
export interface CodegenAssetHint {
relativePath: string;
sourceNodeId: string;
sourceNodeName?: string;
sourceKind: 'image-node' | 'image-fill';
}
export interface CodegenBundleManifest {
version: 2;
framework: string;
entry: {
codeFile: string;
};
generatedAt: string;
code: {
path: string;
size: number;
sha256: string;
};
assets: Array<{
id: string;
relativePath: string;
zipPath: string;
mimeType: string;
size: number;
sha256: string;
sourceNodeId: string;
sourceNodeName?: string;
sourceKind: 'image-node' | 'image-fill';
}>;
}
const DATA_URL_PREFIX = 'data:';
function slugifyName(name: string | undefined, fallback: string): string {
const normalized = (name ?? fallback)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || fallback;
}
function inferExtensionFromMimeType(mimeType: string): string {
const mapped = mimeType.split('/')[1]?.toLowerCase() ?? 'bin';
if (mapped === 'jpeg') return 'jpg';
if (mapped === 'svg+xml') return 'svg';
return mapped.replace(/[^a-z0-9]+/g, '') || 'bin';
}
function decodeBase64(base64: string): Uint8Array {
if (typeof Buffer !== 'undefined') {
return Uint8Array.from(Buffer.from(base64, 'base64'));
}
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function parseDataUrl(url: string): { mimeType: string; bytes: Uint8Array } | null {
if (!url.startsWith(DATA_URL_PREFIX)) return null;
const match = url.match(/^data:([^;,]+);base64,([\s\S]+)$/);
if (!match) return null;
const [, mimeType, base64] = match;
return {
mimeType,
bytes: decodeBase64(base64),
};
}
export async function hashBytesToSha256Hex(bytes: Uint8Array): Promise<string> {
if (globalThis.crypto?.subtle) {
const copiedBytes = Uint8Array.from(bytes);
const digest = await globalThis.crypto.subtle.digest('SHA-256', copiedBytes);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
const { createHash } = await import('node:crypto');
return createHash('sha256').update(bytes).digest('hex');
}
/**
* Extract data URL / base64 payloads from codegen-related nodes into concrete
* asset file descriptors and rewrite `src` / `fill.url` to stable relative paths.
*
* This keeps prompts focused on `./assets/...` paths instead of sending raw base64
* blobs to the model.
*/
export function extractCodegenAssets(nodes: PenNode[]): {
nodes: PenNode[];
assets: CodegenAssetFile[];
} {
const sanitizedNodes = structuredClone(nodes) as PenNode[];
const assets: CodegenAssetFile[] = [];
const seenByDataUrl = new Map<string, CodegenAssetFile>();
let assetIndex = 1;
const materializeAsset = (
sourceUrl: string,
node: PenNode,
sourceKind: 'image-node' | 'image-fill',
): string => {
const parsed = parseDataUrl(sourceUrl);
if (!parsed) return sourceUrl;
const existing = seenByDataUrl.get(sourceUrl);
if (existing) return existing.relativePath;
const ext = inferExtensionFromMimeType(parsed.mimeType);
const fileStem = slugifyName(node.name, sourceKind === 'image-node' ? 'image' : 'image-fill');
const fileName = `${fileStem}-${assetIndex}.${ext}`;
assetIndex++;
const asset: CodegenAssetFile = {
id: `asset-${assets.length + 1}`,
relativePath: `./assets/${fileName}`,
zipPath: `assets/${fileName}`,
mimeType: parsed.mimeType,
bytes: parsed.bytes,
sourceNodeId: node.id,
sourceNodeName: node.name,
sourceKind,
};
assets.push(asset);
seenByDataUrl.set(sourceUrl, asset);
return asset.relativePath;
};
const sanitizeFills = (node: PenNode, fills: PenFill[] | undefined): PenFill[] | undefined => {
if (!Array.isArray(fills) || fills.length === 0) return fills;
return fills.map((fill) => {
if (fill.type !== 'image') return fill;
return {
...fill,
url: materializeAsset(fill.url, node, 'image-fill'),
};
});
};
const visit = (node: PenNode): PenNode => {
const nextNode = { ...node } as PenNode;
if (nextNode.type === 'image' && typeof nextNode.src === 'string') {
nextNode.src = materializeAsset(nextNode.src, nextNode, 'image-node');
}
if ('fill' in nextNode) {
nextNode.fill = sanitizeFills(nextNode, nextNode.fill);
}
if ('children' in nextNode && Array.isArray(nextNode.children)) {
nextNode.children = nextNode.children.map(visit);
}
return enrichNodeLocallyForAIConsumerView(nextNode);
};
return {
nodes: sanitizedNodes.map(visit),
assets,
};
}
export function collectChunkAssetHints(
chunkNodes: PenNode[],
assets: CodegenAssetFile[],
): CodegenAssetHint[] {
const hints: CodegenAssetHint[] = [];
const seen = new Set<string>();
const assetByPath = new Map(assets.map((asset) => [asset.relativePath, asset]));
const visit = (node: PenNode) => {
const pushHint = (relativePath: string, sourceKind: 'image-node' | 'image-fill') => {
if (seen.has(relativePath)) return;
const asset = assetByPath.get(relativePath);
if (!asset) return;
seen.add(relativePath);
hints.push({
relativePath,
sourceNodeId: node.id,
sourceNodeName: node.name,
sourceKind,
});
};
if (node.type === 'image' && typeof node.src === 'string' && node.src.startsWith('./assets/')) {
pushHint(node.src, 'image-node');
}
if ('fill' in node && Array.isArray(node.fill)) {
for (const fill of node.fill) {
if (fill.type !== 'image' || typeof fill.url !== 'string') continue;
if (fill.url.startsWith('./assets/')) {
pushHint(fill.url, 'image-fill');
}
}
}
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) visit(child);
}
};
for (const node of chunkNodes) visit(node);
return hints;
}
export async function buildCodegenBundleManifest(options: {
framework: string;
codeFile: string;
codeBytes: Uint8Array;
assets: CodegenAssetFile[];
}): Promise<CodegenBundleManifest> {
const codeSha256 = await hashBytesToSha256Hex(options.codeBytes);
const assetsWithHashes = await Promise.all(
options.assets.map(async (asset) => ({
id: asset.id,
relativePath: asset.relativePath,
zipPath: asset.zipPath,
mimeType: asset.mimeType,
size: asset.bytes.byteLength,
sha256: await hashBytesToSha256Hex(asset.bytes),
sourceNodeId: asset.sourceNodeId,
sourceNodeName: asset.sourceNodeName,
sourceKind: asset.sourceKind,
})),
);
return {
version: 2,
framework: options.framework,
entry: {
codeFile: options.codeFile,
},
generatedAt: new Date().toISOString(),
code: {
path: options.codeFile,
size: options.codeBytes.byteLength,
sha256: codeSha256,
},
assets: assetsWithHashes,
};
}

View file

@ -1,9 +1,8 @@
// 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-types';
import type { PenNode } from '@zseven-w/pen-types';
import { nodeTreeToSummary } from '@zseven-w/pen-core';
import type { CodegenAssetHint } from './codegen-assets';
function loadSkill(name: string): string {
return getSkillByName(name)?.content ?? '';
@ -73,6 +72,7 @@ export function buildChunkPrompt(
framework: Framework,
suggestedComponentName: string,
depContracts: ChunkContract[],
assetHints: CodegenAssetHint[] = [],
): { system: string; user: string } {
const chunkSkill = loadSkill('codegen-chunk');
const frameworkSkill = loadSkill(`codegen-${framework}`);
@ -91,6 +91,20 @@ export function buildChunkPrompt(
].join('\n')
: '';
const assetSection =
assetHints.length > 0
? [
'',
'## Exported Image Assets',
'The following image assets were exported from the design. Use these relative paths directly as src/background-image URLs. Do NOT inline base64.',
'',
...assetHints.map(
(asset) =>
`- ${asset.relativePath} (${asset.sourceKind}, node: ${asset.sourceNodeName ?? asset.sourceNodeId})`,
),
].join('\n')
: '';
return {
system: [chunkSkill, '', '---', '', frameworkSkill].join('\n'),
user: [
@ -99,6 +113,7 @@ export function buildChunkPrompt(
'Nodes (JSON):',
compactNodes(nodes),
depSection,
assetSection,
'',
'Output the code followed by ---CONTRACT--- and the JSON contract.',
].join('\n'),
@ -119,6 +134,7 @@ export function buildAssemblyPrompt(
plan: CodePlanFromAI,
framework: Framework,
variables?: Record<string, unknown>,
exportedAssetPaths: string[] = [],
): { system: string; user: string } {
const assemblySkill = loadSkill('codegen-assembly');
const frameworkSkill = loadSkill(`codegen-${framework}`);
@ -136,6 +152,16 @@ export function buildAssemblyPrompt(
})
.join('\n\n');
const assetSection =
exportedAssetPaths.length > 0
? [
'Exported image assets are available under ./assets/.',
'Keep any existing ./assets/... references unchanged in the final code.',
`Assets: ${exportedAssetPaths.join(', ')}`,
'',
].join('\n')
: '';
return {
system: [assemblySkill, '', '---', '', frameworkSkill].join('\n'),
user: [
@ -145,6 +171,7 @@ export function buildAssemblyPrompt(
`Shared styles: ${JSON.stringify(plan.sharedStyles)}`,
variables ? `Design variables: ${JSON.stringify(variables)}` : '',
'',
assetSection,
'## Chunks',
'',
chunksSection,

View file

@ -0,0 +1,632 @@
import type { PenNode } from '@/types/pen';
import type {
ImageFill,
ImageOriginalSize,
ImageTransform,
PenEffect,
PenFill,
} from '@/types/styles';
const DATA_URL_PREFIX = 'data:';
const IMAGE_TRANSFORM_EPSILON = 0.000001;
function decodeBase64(base64: string): Uint8Array {
if (typeof Buffer !== 'undefined') {
return Uint8Array.from(Buffer.from(base64, 'base64'));
}
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function parseDataUrl(url: string): { mimeType: string; bytes: Uint8Array } | null {
if (!url.startsWith(DATA_URL_PREFIX)) return null;
const match = url.match(/^data:([^;,]+);base64,([\s\S]+)$/);
if (!match) return null;
const [, mimeType, base64] = match;
return {
mimeType,
bytes: decodeBase64(base64),
};
}
function readBigEndianUint32(bytes: Uint8Array, offset: number): number {
return (
((bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]) >>>
0
);
}
function readBigEndianUint16(bytes: Uint8Array, offset: number): number {
return (bytes[offset] << 8) | bytes[offset + 1];
}
function readLittleEndianUint16(bytes: Uint8Array, offset: number): number {
return bytes[offset] | (bytes[offset + 1] << 8);
}
function normalizeImageDimension(value: number): number | null {
if (!Number.isFinite(value) || value <= 0) return null;
const rounded = Math.round(value);
if (Math.abs(value - rounded) < 0.001) return rounded;
return Number(value.toFixed(3));
}
function normalizeOriginalSize(width: number, height: number): ImageOriginalSize | undefined {
const normalizedWidth = normalizeImageDimension(width);
const normalizedHeight = normalizeImageDimension(height);
if (!normalizedWidth || !normalizedHeight) return undefined;
return {
width: normalizedWidth,
height: normalizedHeight,
};
}
function isValidOriginalSize(size: ImageOriginalSize | undefined): size is ImageOriginalSize {
return Boolean(
size &&
Number.isFinite(size.width) &&
size.width > 0 &&
Number.isFinite(size.height) &&
size.height > 0,
);
}
function tryReadPngSize(bytes: Uint8Array): ImageOriginalSize | undefined {
if (bytes.length < 24) return undefined;
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (!signature.every((value, index) => bytes[index] === value)) return undefined;
return normalizeOriginalSize(readBigEndianUint32(bytes, 16), readBigEndianUint32(bytes, 20));
}
function tryReadGifSize(bytes: Uint8Array): ImageOriginalSize | undefined {
if (bytes.length < 10) return undefined;
const header = String.fromCharCode(...bytes.slice(0, 6));
if (header !== 'GIF87a' && header !== 'GIF89a') return undefined;
return normalizeOriginalSize(readLittleEndianUint16(bytes, 6), readLittleEndianUint16(bytes, 8));
}
function tryReadJpegSize(bytes: Uint8Array): ImageOriginalSize | undefined {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined;
let offset = 2;
while (offset + 8 < bytes.length) {
if (bytes[offset] !== 0xff) {
offset += 1;
continue;
}
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
if (offset >= bytes.length) return undefined;
const marker = bytes[offset];
offset += 1;
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
continue;
}
if (offset + 1 >= bytes.length) return undefined;
const segmentLength = readBigEndianUint16(bytes, offset);
if (segmentLength < 2 || offset + segmentLength > bytes.length) return undefined;
const isStartOfFrame = [
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
].includes(marker);
if (isStartOfFrame) {
if (segmentLength < 7) return undefined;
return normalizeOriginalSize(
readBigEndianUint16(bytes, offset + 5),
readBigEndianUint16(bytes, offset + 3),
);
}
offset += segmentLength;
}
return undefined;
}
function inferImageSizeFromDataUrl(url: string): ImageOriginalSize | undefined {
const parsed = parseDataUrl(url);
if (!parsed) return undefined;
return (
tryReadPngSize(parsed.bytes) ?? tryReadGifSize(parsed.bytes) ?? tryReadJpegSize(parsed.bytes)
);
}
function inferOriginalSizeFromTransform(
node: PenNode,
transform: ImageTransform | undefined,
): ImageOriginalSize | undefined {
if (!transform) return undefined;
const measurableNode = node as PenNode & { width?: number | string; height?: number | string };
if (typeof measurableNode.width !== 'number' || typeof measurableNode.height !== 'number')
return undefined;
if (
Math.abs(transform.m01) > IMAGE_TRANSFORM_EPSILON ||
Math.abs(transform.m10) > IMAGE_TRANSFORM_EPSILON ||
Math.abs(transform.m00) < IMAGE_TRANSFORM_EPSILON ||
Math.abs(transform.m11) < IMAGE_TRANSFORM_EPSILON
) {
return undefined;
}
return normalizeOriginalSize(
Math.abs(measurableNode.width / transform.m00),
Math.abs(measurableNode.height / transform.m11),
);
}
function inferImageFillOriginalSize(node: PenNode, fill: ImageFill): ImageOriginalSize | undefined {
if (isValidOriginalSize(fill.originalSize)) return fill.originalSize;
return (
inferImageSizeFromDataUrl(fill.url) ?? inferOriginalSizeFromTransform(node, fill.transform)
);
}
function buildImageFillExplain(fill: ImageFill): string | undefined {
if (typeof fill.explain === 'string' && fill.explain.trim().length > 0) {
return fill.explain;
}
if (!fill.transform) return undefined;
const mode = fill.mode ?? 'fill';
return `This is not a full-image ${mode}; the source image is cropped before being mapped into the target bounds`;
}
export function enrichImageFillForAIConsumerView(node: PenNode, fill: ImageFill): ImageFill {
const originalSize = inferImageFillOriginalSize(node, fill);
const explain = buildImageFillExplain(fill);
return {
...fill,
...(originalSize ? { originalSize } : {}),
...(explain ? { explain } : {}),
};
}
function buildLinearGradientExplain(
fill: Extract<PenFill, { type: 'linear_gradient' }>,
): string | undefined {
if (typeof fill.explain === 'string' && fill.explain.trim().length > 0) return fill.explain;
const stopCount = Array.isArray(fill.stops) ? fill.stops.length : 0;
const angle = typeof fill.angle === 'number' ? Math.round(fill.angle * 100) / 100 : 0;
return `This is a linear gradient fill angled at ${angle}deg with ${stopCount} color stops, so colors transition smoothly along that direction`;
}
function buildRadialGradientExplain(
fill: Extract<PenFill, { type: 'radial_gradient' }>,
): string | undefined {
if (typeof fill.explain === 'string' && fill.explain.trim().length > 0) return fill.explain;
const stopCount = Array.isArray(fill.stops) ? fill.stops.length : 0;
const cx = typeof fill.cx === 'number' ? Math.round(fill.cx * 100) : 50;
const cy = typeof fill.cy === 'number' ? Math.round(fill.cy * 100) : 50;
const radius = typeof fill.radius === 'number' ? Math.round(fill.radius * 100) : 50;
return `This is a radial gradient fill centered around ${cx}% ${cy}% with a radius of about ${radius}% and ${stopCount} color stops`;
}
function enrichFillForAIConsumerView(node: PenNode, fill: PenFill): PenFill {
if (fill.type === 'image') return enrichImageFillForAIConsumerView(node, fill);
if (fill.type === 'linear_gradient') {
const explain = buildLinearGradientExplain(fill);
return explain ? { ...fill, explain } : fill;
}
if (fill.type === 'radial_gradient') {
const explain = buildRadialGradientExplain(fill);
return explain ? { ...fill, explain } : fill;
}
return fill;
}
function buildImageNodeExplain(node: PenNode): string | undefined {
if (node.type !== 'image') return undefined;
const fit = node.objectFit ?? 'fill';
switch (fit) {
case 'fit':
return 'This is an image node. objectFit=fit keeps the whole image visible and may leave empty space';
case 'crop':
return 'This is an image node. objectFit=crop uses cover to fill the container and may crop the edges';
case 'tile':
return 'This is an image node. objectFit=tile repeats the source image to tile the container';
default:
return 'This is an image node. objectFit=fill stretches the image to fill the container';
}
}
function buildTextNodeExplain(node: PenNode): string | undefined {
if (node.type !== 'text') return undefined;
const parts: string[] = [];
if (node.textGrowth === 'auto') {
parts.push(
'This is a text node. textGrowth=auto prefers natural single-line expansion and usually does not wrap at a fixed width',
);
} else if (node.textGrowth === 'fixed-width') {
parts.push(
'This is a text node. textGrowth=fixed-width wraps text to the current width and grows vertically with the content',
);
} else if (node.textGrowth === 'fixed-width-height') {
parts.push(
'This is a text node. textGrowth=fixed-width-height lays text out inside a fixed width/height box and may clip overflow',
);
}
if (typeof node.lineHeight === 'number') {
parts.push(`Line-height multiplier is ${Math.round(node.lineHeight * 1000) / 1000}`);
}
if (node.textAlign && node.textAlign !== 'left') {
parts.push(`Horizontal alignment is ${describeTextAlign(node.textAlign)}`);
}
if (node.textAlignVertical && node.textAlignVertical !== 'top') {
parts.push(`Vertical alignment is ${describeTextAlignVertical(node.textAlignVertical)}`);
}
if (parts.length === 0) return undefined;
return parts.join('. ');
}
function buildLayoutExplain(node: PenNode): string | undefined {
const containerNode = node as PenNode & {
layout?: 'none' | 'vertical' | 'horizontal';
gap?: number | string;
padding?: number | [number, number] | [number, number, number, number] | string;
justifyContent?: string;
alignItems?: string;
};
if (containerNode.layout !== 'vertical' && containerNode.layout !== 'horizontal')
return undefined;
const layoutLabel = containerNode.layout === 'vertical' ? 'vertical' : 'horizontal';
const parts = [`This is a ${layoutLabel} auto-layout container`];
if (containerNode.gap !== undefined) parts.push(`Child gap is ${String(containerNode.gap)}`);
if (containerNode.padding !== undefined)
parts.push(`Container padding is ${formatPadding(containerNode.padding)}`);
if (containerNode.justifyContent)
parts.push(`Main-axis alignment is ${describeFlexAlign(containerNode.justifyContent)}`);
if (containerNode.alignItems)
parts.push(`Cross-axis alignment is ${describeFlexAlign(containerNode.alignItems)}`);
return parts.join(', ');
}
function buildClipExplain(node: PenNode): string | undefined {
const containerNode = node as PenNode & { clipContent?: boolean };
if (containerNode.clipContent !== true) return undefined;
return 'This container clips children that overflow its bounds';
}
function parseSizingHint(value: string): { kind: string; hint?: string } {
const match = value.match(/^([a-z_]+)\(([^)]+)\)$/);
if (!match) return { kind: value };
return {
kind: match[1],
hint: match[2]?.trim(),
};
}
function describeSizingValue(
axis: 'width' | 'height',
value: number | string | undefined,
): string | undefined {
if (typeof value === 'number') {
return `${axis === 'width' ? 'Width' : 'Height'} is fixed at ${value}px`;
}
if (typeof value !== 'string' || value.length === 0) return undefined;
const { kind, hint } = parseSizingHint(value);
if (kind === 'fill_container') {
const base = `${axis === 'width' ? 'Width' : 'Height'} stretches to fill the available space in the parent container`;
return hint ? `${base}, with a suggested value of about ${hint}px` : base;
}
if (kind === 'fit_content') {
const base = `${axis === 'width' ? 'Width' : 'Height'} grows automatically with its content`;
return hint ? `${base}, with a suggested value of about ${hint}px` : base;
}
return `${axis === 'width' ? 'Width' : 'Height'} uses expression ${value}`;
}
function buildSizingExplain(node: PenNode): string | undefined {
const measurableNode = node as PenNode & { width?: number | string; height?: number | string };
const parts = [
describeSizingValue('width', measurableNode.width),
describeSizingValue('height', measurableNode.height),
].filter((part): part is string => Boolean(part));
if (parts.length === 0) return undefined;
return parts.join(', ');
}
function isVariableRef(value: unknown): value is string {
return typeof value === 'string' && value.startsWith('$') && value.length > 1;
}
function collectVariableRefHints(node: PenNode): string[] {
const hints = new Set<string>();
if (isVariableRef(node.opacity)) hints.add(`opacity uses design token ${node.opacity}`);
const fillNode = node as PenNode & { fill?: PenFill[] };
if (Array.isArray(fillNode.fill)) {
for (const fill of fillNode.fill) {
if (fill.type === 'solid' && isVariableRef(fill.color)) {
hints.add(`fill color uses design token ${fill.color}`);
}
if (
(fill.type === 'linear_gradient' || fill.type === 'radial_gradient') &&
Array.isArray(fill.stops)
) {
for (const stop of fill.stops) {
if (isVariableRef(stop.color)) {
hints.add(`gradient stop color uses design token ${stop.color}`);
}
}
}
}
}
const strokeNode = node as PenNode & {
stroke?: {
thickness?: number | string | [number, number, number, number];
fill?: PenFill[];
};
};
if (strokeNode.stroke) {
if (isVariableRef(strokeNode.stroke.thickness)) {
hints.add(`stroke thickness uses design token ${strokeNode.stroke.thickness}`);
}
if (Array.isArray(strokeNode.stroke.fill)) {
for (const fill of strokeNode.stroke.fill) {
if (fill.type === 'solid' && isVariableRef(fill.color)) {
hints.add(`stroke color uses design token ${fill.color}`);
}
}
}
}
const effectNode = node as PenNode & { effects?: PenEffect[] };
if (Array.isArray(effectNode.effects)) {
for (const effect of effectNode.effects) {
if (effect.type === 'shadow') {
if (isVariableRef(effect.color))
hints.add(`shadow color uses design token ${effect.color}`);
if (isVariableRef(effect.blur))
hints.add(`shadow blur radius uses design token ${effect.blur}`);
if (isVariableRef(effect.offsetX))
hints.add(`shadow X offset uses design token ${effect.offsetX}`);
if (isVariableRef(effect.offsetY))
hints.add(`shadow Y offset uses design token ${effect.offsetY}`);
if (isVariableRef(effect.spread))
hints.add(`shadow spread uses design token ${effect.spread}`);
}
if (
(effect.type === 'blur' || effect.type === 'background_blur') &&
isVariableRef(effect.radius)
) {
hints.add(
`${effect.type === 'blur' ? 'blur radius' : 'background blur radius'} uses design token ${effect.radius}`,
);
}
}
}
return Array.from(hints);
}
function buildVariableExplain(node: PenNode): string | undefined {
const hints = collectVariableRefHints(node);
if (hints.length === 0) return undefined;
return `${hints.join('. ')}. These values come from design-system tokens rather than hard-coded constants`;
}
function buildThemeExplain(node: PenNode): string | undefined {
if (!node.theme || Object.keys(node.theme).length === 0) return undefined;
const pairs = Object.entries(node.theme).map(([axis, value]) => `${axis}=${value}`);
return `This node carries theme override context: ${pairs.join(', ')}`;
}
function buildEffectsExplain(node: PenNode): string | undefined {
const effectNode = node as PenNode & { effects?: PenEffect[] };
if (!Array.isArray(effectNode.effects) || effectNode.effects.length === 0) return undefined;
const parts: string[] = [];
for (const effect of effectNode.effects) {
if (effect.type === 'shadow') {
const shadowKind = effect.inner ? 'inner shadow' : 'shadow';
const usesVariable =
isVariableRef(effect.offsetX) ||
isVariableRef(effect.offsetY) ||
isVariableRef(effect.blur) ||
isVariableRef(effect.spread) ||
isVariableRef(effect.color);
if (usesVariable) {
parts.push(`Has ${shadowKind} effect`);
} else {
parts.push(
`Has ${shadowKind} with offset ${effect.offsetX}px ${effect.offsetY}px, blur ${effect.blur}px, spread ${effect.spread}px`,
);
}
continue;
}
if (effect.type === 'blur') {
parts.push(
isVariableRef(effect.radius)
? 'Has foreground blur effect'
: `Has foreground blur with radius ${effect.radius}px`,
);
continue;
}
if (effect.type === 'background_blur') {
parts.push(
isVariableRef(effect.radius)
? 'Has background blur effect'
: `Has background blur with radius ${effect.radius}px`,
);
}
}
if (parts.length === 0) return undefined;
return parts.join('. ');
}
function buildReusableExplain(node: PenNode): string | undefined {
if (node.type !== 'frame') return undefined;
const frameNode = node as PenNode & { reusable?: boolean; slot?: string[] };
const parts: string[] = [];
if (frameNode.reusable === true) {
parts.push('This is a reusable component definition node that other instances can reference');
}
if (Array.isArray(frameNode.slot) && frameNode.slot.length > 0) {
parts.push(`It declares slot regions: ${frameNode.slot.join(', ')}`);
}
if (parts.length === 0) return undefined;
return parts.join('. ');
}
function buildRefExplain(node: PenNode): string | undefined {
if (node.type !== 'ref') return undefined;
const refNode = node as PenNode & {
ref: string;
descendants?: Record<string, Partial<PenNode>>;
};
const overrideCount = refNode.descendants ? Object.keys(refNode.descendants).length : 0;
const parts = [`This is a component instance node referencing source node ${refNode.ref}`];
if (overrideCount > 0) {
parts.push(`This instance overrides ${overrideCount} descendant nodes`);
}
return parts.join('. ');
}
function appendExplain(
baseExplain: string | undefined,
extraExplain: string | undefined,
): string | undefined {
const segments = [baseExplain?.trim(), extraExplain?.trim()].filter(
(segment): segment is string => Boolean(segment && segment.length > 0),
);
if (segments.length === 0) return undefined;
return Array.from(new Set(segments)).join('. ');
}
function formatPadding(
padding: number | [number, number] | [number, number, number, number] | string,
): string {
if (!Array.isArray(padding)) return String(padding);
if (padding.length === 2) return `${padding[0]} ${padding[1]}`;
if (padding.length === 4) return `${padding[0]} ${padding[1]} ${padding[2]} ${padding[3]}`;
return String(padding);
}
function describeFlexAlign(value: string): string {
switch (value) {
case 'start':
return 'start aligned';
case 'center':
return 'center aligned';
case 'end':
return 'end aligned';
case 'space_between':
return 'space between';
case 'space_around':
return 'space around';
default:
return value;
}
}
function describeTextAlign(value: string): string {
switch (value) {
case 'center':
return 'center';
case 'right':
return 'right';
case 'justify':
return 'justified';
default:
return value;
}
}
function describeTextAlignVertical(value: string): string {
switch (value) {
case 'middle':
return 'middle';
case 'bottom':
return 'bottom';
default:
return value;
}
}
export function enrichNodeLocallyForAIConsumerView(node: PenNode): PenNode {
const nextNode = { ...node } as PenNode;
if ('fill' in nextNode && Array.isArray(nextNode.fill) && nextNode.fill.length > 0) {
nextNode.fill = nextNode.fill.map((fill: PenFill) =>
enrichFillForAIConsumerView(nextNode, fill),
);
}
const baseExplain = typeof nextNode.explain === 'string' ? nextNode.explain : undefined;
const withImageExplain = appendExplain(baseExplain, buildImageNodeExplain(nextNode));
const withTextExplain = appendExplain(withImageExplain, buildTextNodeExplain(nextNode));
const withLayoutExplain = appendExplain(withTextExplain, buildLayoutExplain(nextNode));
const withSizingExplain = appendExplain(withLayoutExplain, buildSizingExplain(nextNode));
const withClipExplain = appendExplain(withSizingExplain, buildClipExplain(nextNode));
const withEffectsExplain = appendExplain(withClipExplain, buildEffectsExplain(nextNode));
const withReusableExplain = appendExplain(withEffectsExplain, buildReusableExplain(nextNode));
const withRefExplain = appendExplain(withReusableExplain, buildRefExplain(nextNode));
const withVariableExplain = appendExplain(withRefExplain, buildVariableExplain(nextNode));
const finalExplain = appendExplain(withVariableExplain, buildThemeExplain(nextNode));
if (finalExplain) nextNode.explain = finalExplain;
return nextNode;
}
/**
* Add lightweight semantic enrichment to the canonical AI consumer view.
*
* Design principles:
* - do not change node topology
* - do not introduce runtime noise
* - only add explanation fields for details the model cannot infer directly
* but that still affect reconstruction quality
*
* Current coverage includes image / gradient / sizing / layout / clip / effects /
* reusable/ref. Future text-layout or variable semantics should extend this layer.
*/
export function enrichNodeForAIConsumerView(node: PenNode): PenNode {
const nextNode = enrichNodeLocallyForAIConsumerView(node);
if ('children' in nextNode && Array.isArray(nextNode.children) && nextNode.children.length > 0) {
nextNode.children = nextNode.children.map(enrichNodeForAIConsumerView);
}
return nextNode;
}

View file

@ -3,52 +3,115 @@
* Prevents unbounded growth of chat history and context size.
*/
const DEFAULT_MAX_MESSAGES = 10;
const DEFAULT_MAX_CHARS = 32_000;
export const DEFAULT_MAX_MESSAGES = 10;
export const DEFAULT_MAX_CHARS = 32_000;
/**
* Leave a safety margin below the upstream 1,048,576 character limit.
* This is a local guardrail, not an exact upstream token/character calculator.
*/
export const MAX_CHAT_REQUEST_CHARS = 900_000;
type MessageWithOptionalAttachments = {
role: string;
content: string;
attachments?: unknown[];
};
function stripHistoricalAttachments<T extends MessageWithOptionalAttachments>(
message: T,
keepAttachments: boolean,
): T {
if (keepAttachments || !Array.isArray(message.attachments) || message.attachments.length === 0) {
return message;
}
const cloned = { ...message } as T & { attachments?: unknown[] };
delete cloned.attachments;
return cloned;
}
/**
* Estimate the character count of a chat request payload.
*
* Notes:
* - This is an approximation for local preflight checks, not upstream billing
* or the true model context length.
* - The goal is to reject obviously oversized requests early with an actionable error.
*/
export function estimateChatPayloadChars(payload: unknown): number {
try {
return JSON.stringify(payload).length;
} catch {
return Number.MAX_SAFE_INTEGER;
}
}
export function formatChatPayloadTooLargeError(
payloadChars: number,
limit: number = MAX_CHAT_REQUEST_CHARS,
): string {
return [
`AI input is too large (${payloadChars.toLocaleString()} chars > ${limit.toLocaleString()} safe local limit).`,
'Please remove older image attachments or large pasted content, start a new chat, or simplify the current selection and retry.',
].join(' ');
}
/**
* Sliding window for chat history.
* Keeps the most recent messages while respecting character limits.
* Always preserves the first user message for context continuity.
*/
export function trimChatHistory<T extends { role: string; content: string }>(
export function trimChatHistory<T extends MessageWithOptionalAttachments>(
messages: T[],
maxMessages: number = DEFAULT_MAX_MESSAGES,
maxChars: number = DEFAULT_MAX_CHARS,
): T[] {
if (messages.length <= maxMessages) {
const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0);
if (totalChars <= maxChars) return messages;
if (totalChars <= maxChars) {
const latestAttachmentMessage = [...messages]
.reverse()
.find((m) => Array.isArray(m.attachments) && m.attachments.length > 0);
return messages.map((m) => stripHistoricalAttachments(m, m === latestAttachmentMessage));
}
}
// Always keep the first user message for context continuity
const firstUser = messages.find((m) => m.role === 'user');
const recentMessages = messages.slice(-maxMessages);
const latestAttachmentMessage = [...messages]
.reverse()
.find((m) => Array.isArray(m.attachments) && m.attachments.length > 0);
const window: T[] = [];
let charCount = 0;
// Add first user message if it's not already in the recent window
if (firstUser && !recentMessages.includes(firstUser)) {
window.push(firstUser);
charCount += firstUser.content.length;
const sanitizedFirstUser = stripHistoricalAttachments(
firstUser,
firstUser === latestAttachmentMessage,
);
window.push(sanitizedFirstUser);
charCount += sanitizedFirstUser.content.length;
}
// Add recent messages, respecting char limit
for (const msg of recentMessages) {
const msgChars = msg.content.length;
const sanitizedMessage = stripHistoricalAttachments(msg, msg === latestAttachmentMessage);
const msgChars = sanitizedMessage.content.length;
if (charCount + msgChars > maxChars) {
// Truncate this message to fit
const remaining = maxChars - charCount;
if (remaining > 200) {
window.push({
...msg,
content: msg.content.slice(0, remaining) + '\n[...truncated...]',
...sanitizedMessage,
content: sanitizedMessage.content.slice(0, remaining) + '\n[...truncated...]',
} as T);
}
break;
}
window.push(msg);
window.push(sanitizedMessage);
charCount += msgChars;
}

View file

@ -89,8 +89,14 @@ export async function executeSubAgents(
abortSignal,
);
// Retry once on failure (e.g. socket closed by provider)
if (result.error && result.nodes.length === 0 && !abortSignal?.aborted) {
// Retry once on failure (e.g. socket closed by provider). Skip retry
// when the provider refused the request shape or content (HTTP 400/451)
// — retrying the same prompt will hit the same determinstic refusal and
// just wastes another round-trip (StepFun's 451 scan can take minutes).
const isNonRetryable =
!!result.error &&
/HTTP 4(0[01]|29|51)|content blocked|authentication failed|censorship/i.test(result.error);
if (result.error && result.nodes.length === 0 && !abortSignal?.aborted && !isNonRetryable) {
console.warn(`[orchestrator] subtask ${i} failed, retrying: ${result.error}`);
result = await executeSubAgent(
plan.subtasks[i],
@ -107,6 +113,31 @@ export async function executeSubAgents(
);
}
// Minimal-skills fallback: re-run just this failing subtask with a
// ~3KB kernel prompt (schema + jsonl-format only). Don't re-run any
// earlier successful subtasks. Skip when the provider gave a
// deterministic refusal (401/451/content-blocked) — a smaller prompt
// won't get past the same policy check.
if (result.error && result.nodes.length === 0 && !abortSignal?.aborted && !isNonRetryable) {
console.warn(
`[orchestrator] subtask ${i} still empty after retry, falling back to minimal skills: ${result.error}`,
);
result = await executeSubAgent(
plan.subtasks[i],
plan,
request,
preparedPrompt,
timeoutOptions,
progress,
i,
callbacks,
undefined,
abortSignal,
true, // reducedComplexity
true, // minimalSkills
);
}
if (result.error && result.nodes.length === 0) {
throw new Error(result.error);
}
@ -166,7 +197,7 @@ export async function executeSubAgents(
await acquireSlot();
try {
const result = await executeSubAgent(
let result = await executeSubAgent(
plan.subtasks[idx],
plan,
request,
@ -178,6 +209,36 @@ export async function executeSubAgents(
undefined,
abortSignal,
);
// Minimal-skills fallback — retry just this subtask with a ~3KB
// kernel prompt if the full-skills attempt produced no nodes.
// See the sequential path for rationale.
if (result.error && result.nodes.length === 0 && !abortSignal?.aborted) {
const nonRetryable =
/HTTP 4(0[01]|29|51)|content blocked|authentication failed|censorship/i.test(
result.error,
);
if (!nonRetryable) {
console.warn(
`[orchestrator] subtask ${idx} empty, falling back to minimal skills: ${result.error}`,
);
result = await executeSubAgent(
plan.subtasks[idx],
plan,
request,
preparedPrompt,
timeoutOptions,
progress,
idx,
callbacks,
undefined,
abortSignal,
true, // reducedComplexity
true, // minimalSkills
);
}
}
results[idx] = result;
if (result.nodes.length > 0) {
@ -232,6 +293,7 @@ async function executeSubAgent(
promptOverride?: string,
abortSignal?: AbortSignal,
reducedComplexity = false,
minimalSkills = false,
): Promise<SubAgentResult> {
const animated = callbacks?.animated ?? false;
const progressEntry = progress.subtasks[index];
@ -275,7 +337,17 @@ async function executeSubAgent(
// See `sub-agent-debug-flags.ts` for the toggles. All branches here
// are no-ops when the corresponding flag is false (the default).
let resolvedSkills = genCtx.skills;
if (SUB_AGENT_DEBUG_FLAGS.SKILLS_MINIMAL_ONLY) {
// `minimalSkills` is the last-ditch fallback: after a full-skill attempt
// and a reduced-complexity retry both returned empty, re-run this ONE
// subtask with just the schema + JSONL-format kernel. A 19KB system
// prompt times out StepFun's safety scanner and occasionally yields
// pure-reasoning streams on weaker models; the ~3KB kernel gives the
// model a much better chance of actually emitting nodes.
if (minimalSkills) {
resolvedSkills = resolvedSkills.filter(
(s) => s.meta.name === 'schema' || s.meta.name === 'jsonl-format',
);
} else if (SUB_AGENT_DEBUG_FLAGS.SKILLS_MINIMAL_ONLY) {
resolvedSkills = resolvedSkills.filter(
(s) => s.meta.name === 'schema' || s.meta.name === 'jsonl-format',
);

View file

@ -0,0 +1,461 @@
import type { PenNode } from '@/types/pen';
import { encode as encodeZip } from 'uzip';
import {
extractCodegenAssets,
hashBytesToSha256Hex,
type CodegenAssetFile,
} from './codegen-assets';
export type AIStructureBundleScopeMode = 'selection' | 'page';
export interface AIStructureBundleScope {
mode: AIStructureBundleScopeMode;
activePageId: string | null;
selectedIds: string[];
exportedRootIds: string[];
exportedRootCount: number;
exportedNodeCount: number;
}
export interface AIStructureBundleViewFile {
kind: 'ai-structure-view';
version: 1;
view: 'raw' | 'sanitized';
consumer: boolean;
nodeCount: number;
summary?: string;
highlights?: string[];
nodes: PenNode[];
}
export interface AIStructureBundleRef {
pointer: string;
nodeId: string;
field: 'src' | 'fill.url';
value: string;
}
export interface AIStructureBundleAssetIndex {
id: string;
relativePath: string;
zipPath: string;
mimeType: string;
size: number;
sha256: string;
sourceNodeId: string;
sourceNodeName?: string;
sourceKind: 'image-node' | 'image-fill';
rawRefs: AIStructureBundleRef[];
sanitizedRefs: AIStructureBundleRef[];
}
export interface AIStructureBundleManifest {
kind: 'ai-structure-bundle';
version: 1;
consumerView: 'sanitized';
generatedAt: string;
scope: AIStructureBundleScope;
views: {
raw: {
path: 'views/raw.json';
nodeCount: number;
assetReferencePrefix: 'asset://';
};
sanitized: {
path: 'views/sanitized.json';
nodeCount: number;
assetBasePath: './assets/';
};
};
assets: AIStructureBundleAssetIndex[];
}
export interface AIStructureBundle {
fileName: string;
manifest: AIStructureBundleManifest;
rawView: AIStructureBundleViewFile;
sanitizedView: AIStructureBundleViewFile;
assets: CodegenAssetFile[];
zipEntries: Record<string, Uint8Array>;
}
export interface BuildAIStructureBundleOptions {
nodes: PenNode[];
activePageId: string | null;
selectedIds: string[];
}
const RAW_ASSET_PREFIX = 'asset://';
const RAW_VIEW_PATH = 'views/raw.json' as const;
const SANITIZED_VIEW_PATH = 'views/sanitized.json' as const;
const STRUCTURE_BUNDLE_FILE_NAME = 'ai-structure-bundle.zip';
interface MutableAssetIndexRecord {
id: string;
relativePath: string;
zipPath: string;
mimeType: string;
size: number;
sha256: string;
sourceNodeId: string;
sourceNodeName?: string;
sourceKind: 'image-node' | 'image-fill';
rawRefs: AIStructureBundleRef[];
sanitizedRefs: AIStructureBundleRef[];
}
function serializeJson(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value, null, 2));
}
function countNodes(nodes: PenNode[]): number {
let total = 0;
const visit = (node: PenNode) => {
total += 1;
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) visit(child);
}
};
for (const node of nodes) visit(node);
return total;
}
function buildSanitizedViewSummary(nodes: PenNode[]): { summary: string; highlights: string[] } {
const signals = {
hasVariables: false,
hasThemeOverrides: false,
hasReusableOrRef: false,
hasImageTransform: false,
hasGradients: false,
hasLayout: false,
hasClip: false,
hasTextSemantics: false,
};
const visit = (node: PenNode) => {
if (typeof node.explain === 'string') {
if (node.explain.includes('design token')) signals.hasVariables = true;
if (node.explain.includes('theme override context')) signals.hasThemeOverrides = true;
if (
node.explain.includes('reusable component definition node') ||
node.explain.includes('component instance node')
) {
signals.hasReusableOrRef = true;
}
if (node.explain.includes('auto-layout')) signals.hasLayout = true;
if (node.explain.includes('clips children that overflow its bounds')) signals.hasClip = true;
if (node.explain.includes('text node') || node.explain.includes('Line-height multiplier')) {
signals.hasTextSemantics = true;
}
}
const fillNode = node as PenNode & { fill?: Array<{ type?: string; transform?: unknown }> };
if (Array.isArray(fillNode.fill)) {
for (const fill of fillNode.fill) {
if (fill.type === 'image' && fill.transform) signals.hasImageTransform = true;
if (fill.type === 'linear_gradient' || fill.type === 'radial_gradient')
signals.hasGradients = true;
}
}
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) visit(child);
}
};
for (const node of nodes) visit(node);
const highlights: string[] = [];
if (signals.hasVariables) highlights.push('Includes design token references');
if (signals.hasThemeOverrides) highlights.push('Includes theme override context');
if (signals.hasReusableOrRef)
highlights.push('Includes component definitions and instance reference relationships');
if (signals.hasImageTransform) highlights.push('Includes image crop/mapping semantics');
if (signals.hasGradients) highlights.push('Includes gradient fill semantics');
if (signals.hasLayout) highlights.push('Includes auto-layout container semantics');
if (signals.hasClip) highlights.push('Includes clipping container semantics');
if (signals.hasTextSemantics) highlights.push('Includes text layout semantics');
if (highlights.length === 0) highlights.push('Primarily basic geometry and style structure');
return {
summary: `This is the sanitized structural view intended for direct AI consumption, containing ${countNodes(nodes)} nodes. Key traits: ${highlights.join(', ')}. Treat these higher-level semantics as default constraints before reading individual nodes.`,
highlights,
};
}
function buildScope(options: BuildAIStructureBundleOptions): AIStructureBundleScope {
const exportedRootIds = options.nodes.map((node) => node.id);
return {
mode: options.selectedIds.length > 0 ? 'selection' : 'page',
activePageId: options.activePageId,
selectedIds: [...options.selectedIds],
exportedRootIds,
exportedRootCount: exportedRootIds.length,
exportedNodeCount: countNodes(options.nodes),
};
}
function createAssetIndexSeed(asset: CodegenAssetFile): MutableAssetIndexRecord {
return {
id: asset.id,
relativePath: asset.relativePath,
zipPath: asset.zipPath,
mimeType: asset.mimeType,
size: asset.bytes.byteLength,
sha256: '',
sourceNodeId: asset.sourceNodeId,
sourceNodeName: asset.sourceNodeName,
sourceKind: asset.sourceKind,
rawRefs: [],
sanitizedRefs: [],
};
}
function buildRawAssetUri(assetId: string): string {
return `${RAW_ASSET_PREFIX}${assetId}`;
}
function collectAssetRefs(options: {
rawNodes: PenNode[];
sanitizedNodes: PenNode[];
assets: CodegenAssetFile[];
}): MutableAssetIndexRecord[] {
const assetByPath = new Map(options.assets.map((asset) => [asset.relativePath, asset]));
const records = new Map(options.assets.map((asset) => [asset.id, createAssetIndexSeed(asset)]));
const pushRef = (
asset: CodegenAssetFile,
bucket: 'rawRefs' | 'sanitizedRefs',
ref: AIStructureBundleRef,
) => {
const record = records.get(asset.id);
if (!record) return;
record[bucket].push(ref);
};
const visit = (rawNode: PenNode, sanitizedNode: PenNode, nodePointer: string) => {
const nodeId = rawNode.id;
const rawNodeWithImage = rawNode as PenNode & {
src?: string;
fill?: Array<{ type?: string; url?: string }>;
};
const sanitizedNodeWithImage = sanitizedNode as PenNode & {
src?: string;
fill?: Array<{ type?: string; url?: string }>;
};
// ---------------------------------------------------------------------
// Image nodes: keep `asset://asset-id` in the raw view so the original
// image-source field remains explicit, while the sanitized view continues
// to use the existing `./assets/...` relative path convention.
// ---------------------------------------------------------------------
if (typeof sanitizedNodeWithImage.src === 'string') {
const asset = assetByPath.get(sanitizedNodeWithImage.src);
if (asset) {
const pointer = `${nodePointer}/src`;
rawNodeWithImage.src = buildRawAssetUri(asset.id);
pushRef(asset, 'rawRefs', { pointer, nodeId, field: 'src', value: rawNodeWithImage.src });
pushRef(asset, 'sanitizedRefs', {
pointer,
nodeId,
field: 'src',
value: sanitizedNodeWithImage.src,
});
}
}
// ---------------------------------------------------------------------
// Image fills: preserve the original fill structure and only replace large
// data URLs with stable asset:// references so both raw and sanitized
// views can trace back through the same asset id.
// ---------------------------------------------------------------------
if (Array.isArray(rawNodeWithImage.fill) && Array.isArray(sanitizedNodeWithImage.fill)) {
const fillCount = Math.min(rawNodeWithImage.fill.length, sanitizedNodeWithImage.fill.length);
for (let index = 0; index < fillCount; index += 1) {
const rawFill = rawNodeWithImage.fill[index];
const sanitizedFill = sanitizedNodeWithImage.fill[index];
if (!rawFill || !sanitizedFill || sanitizedFill.type !== 'image') continue;
if (typeof sanitizedFill.url !== 'string') continue;
const asset = assetByPath.get(sanitizedFill.url);
if (!asset) continue;
const pointer = `${nodePointer}/fill/${index}/url`;
rawNodeWithImage.fill[index] = {
...rawFill,
url: buildRawAssetUri(asset.id),
};
pushRef(asset, 'rawRefs', {
pointer,
nodeId,
field: 'fill.url',
value: rawNodeWithImage.fill[index]?.url ?? buildRawAssetUri(asset.id),
});
pushRef(asset, 'sanitizedRefs', {
pointer,
nodeId,
field: 'fill.url',
value: sanitizedFill.url,
});
}
}
if (
'children' in rawNode &&
Array.isArray(rawNode.children) &&
'children' in sanitizedNode &&
Array.isArray(sanitizedNode.children)
) {
const childCount = Math.min(rawNode.children.length, sanitizedNode.children.length);
for (let index = 0; index < childCount; index += 1) {
visit(
rawNode.children[index],
sanitizedNode.children[index],
`${nodePointer}/children/${index}`,
);
}
}
};
for (let index = 0; index < options.rawNodes.length; index += 1) {
visit(options.rawNodes[index], options.sanitizedNodes[index], `#/nodes/${index}`);
}
return Array.from(records.values());
}
function buildViews(options: { rawNodes: PenNode[]; sanitizedNodes: PenNode[] }): {
rawView: AIStructureBundleViewFile;
sanitizedView: AIStructureBundleViewFile;
} {
const sanitizedSummary = buildSanitizedViewSummary(options.sanitizedNodes);
return {
rawView: {
kind: 'ai-structure-view',
version: 1,
view: 'raw',
consumer: false,
nodeCount: countNodes(options.rawNodes),
nodes: options.rawNodes,
},
sanitizedView: {
kind: 'ai-structure-view',
version: 1,
view: 'sanitized',
consumer: true,
nodeCount: countNodes(options.sanitizedNodes),
summary: sanitizedSummary.summary,
highlights: sanitizedSummary.highlights,
nodes: options.sanitizedNodes,
},
};
}
function buildManifest(options: {
scope: AIStructureBundleScope;
rawView: AIStructureBundleViewFile;
sanitizedView: AIStructureBundleViewFile;
assets: AIStructureBundleAssetIndex[];
}): AIStructureBundleManifest {
return {
kind: 'ai-structure-bundle',
version: 1,
consumerView: 'sanitized',
generatedAt: new Date().toISOString(),
scope: options.scope,
views: {
raw: {
path: RAW_VIEW_PATH,
nodeCount: options.rawView.nodeCount,
assetReferencePrefix: RAW_ASSET_PREFIX,
},
sanitized: {
path: SANITIZED_VIEW_PATH,
nodeCount: options.sanitizedView.nodeCount,
assetBasePath: './assets/',
},
},
assets: options.assets,
};
}
function buildZipEntries(options: {
manifest: AIStructureBundleManifest;
rawView: AIStructureBundleViewFile;
sanitizedView: AIStructureBundleViewFile;
assets: CodegenAssetFile[];
}): Record<string, Uint8Array> {
return {
'manifest.json': serializeJson(options.manifest),
[RAW_VIEW_PATH]: serializeJson(options.rawView),
[SANITIZED_VIEW_PATH]: serializeJson(options.sanitizedView),
...Object.fromEntries(options.assets.map((asset) => [asset.zipPath, asset.bytes])),
};
}
export async function buildAIStructureBundle(
options: BuildAIStructureBundleOptions,
): Promise<AIStructureBundle> {
const scope = buildScope(options);
const rawNodes = structuredClone(options.nodes) as PenNode[];
const { nodes: sanitizedNodes, assets } = extractCodegenAssets(options.nodes);
const assetIndexRecords = collectAssetRefs({
rawNodes,
sanitizedNodes,
assets,
});
// -----------------------------------------------------------------------
// `sha256` must be computed from the actual asset bytes, so fill it in
// after collectAssetRefs to keep both reference relationships and a
// traceable content hash in the manifest.
// -----------------------------------------------------------------------
const assetIndex = await Promise.all(
assetIndexRecords.map(async (record) => {
const asset = assets.find((item) => item.id === record.id);
if (!asset) return record;
return {
...record,
sha256: await hashBytesToSha256Hex(asset.bytes),
};
}),
);
const { rawView, sanitizedView } = buildViews({
rawNodes,
sanitizedNodes,
});
const manifest = buildManifest({
scope,
rawView,
sanitizedView,
assets: assetIndex,
});
return {
fileName: STRUCTURE_BUNDLE_FILE_NAME,
manifest,
rawView,
sanitizedView,
assets,
zipEntries: buildZipEntries({
manifest,
rawView,
sanitizedView,
assets,
}),
};
}
export function encodeAIStructureBundleZip(entries: Record<string, Uint8Array>): ArrayBuffer {
return encodeZip(entries);
}

View file

@ -1,7 +1,9 @@
import type { FigmaPaint, FigmaMatrix } from './figma-types';
import type { PenFill } from '@/types/styles';
import type { ImageOriginalSize, ImageTransform, PenFill } from '@/types/styles';
import { figmaColorToHex } from './figma-color-utils';
const IMAGE_TRANSFORM_EPSILON = 0.000001;
/**
* Convert Figma fillPaints (internal format) to PenFill[].
*/
@ -74,6 +76,8 @@ function mapSingleFill(paint: FigmaPaint): PenFill | null {
type: 'image',
url,
mode: mapScaleMode(paint.imageScaleMode),
originalSize: normalizeOriginalSize(paint.originalImageWidth, paint.originalImageHeight),
transform: normalizeImageTransform(paint.transform),
opacity: paint.opacity,
};
}
@ -91,6 +95,45 @@ function gradientAngleFromTransform(m: FigmaMatrix): number {
return Math.round(90 - mathAngle);
}
function normalizeOriginalSize(width?: number, height?: number): ImageOriginalSize | undefined {
if (
typeof width !== 'number' ||
typeof height !== 'number' ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
function normalizeImageTransform(transform?: FigmaMatrix): ImageTransform | undefined {
if (!transform) return undefined;
if (
Math.abs(transform.m00 - 1) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m01) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m02) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m10) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m11 - 1) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m12) <= IMAGE_TRANSFORM_EPSILON
) {
return undefined;
}
return {
m00: transform.m00,
m01: transform.m01,
m02: transform.m02,
m10: transform.m10,
m11: transform.m11,
m12: transform.m12,
};
}
function mapScaleMode(mode?: string): 'stretch' | 'fill' | 'fit' {
switch (mode) {
case 'FIT':

View file

@ -62,6 +62,8 @@ export interface FigmaPaint {
transform?: FigmaMatrix;
image?: FigmaImage;
imageScaleMode?: 'STRETCH' | 'FIT' | 'FILL' | 'TILE';
originalImageWidth?: number;
originalImageHeight?: number;
}
export type FigmaEffectType =

View file

@ -32,6 +32,7 @@ export type BuiltinProviderPreset =
| 'xiaomi'
| 'modelscope'
| 'stepfun'
| 'stepfun-coding'
| 'nvidia'
| 'custom';

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import type { ContainerProps, FrameNode, PenDocument, PenNode } from '@/types/pen';
import { DEFAULT_PAGE_ID, findNodeInTree } from './document-tree-utils';
import { getNodeVisualPosition, moveNodePreservingVisualPosition } from './document-position-utils';
const rect = (id: string, x?: number, y?: number): PenNode => ({
id,
type: 'rectangle',
x,
y,
width: 50,
height: 20,
});
const frame = (
id: string,
props: Partial<FrameNode & ContainerProps> & { children?: PenNode[] } = {},
): PenNode => ({
id,
type: 'frame',
x: 0,
y: 0,
width: 200,
height: 200,
...props,
});
describe('document-position-utils', () => {
it('reads visual position from auto-layout, not raw child x/y', () => {
const doc: PenDocument = {
version: '1.0.0',
children: [],
pages: [
{
id: DEFAULT_PAGE_ID,
name: 'Page 1',
children: [
frame('parent', {
x: 100,
y: 50,
layout: 'vertical',
padding: [10, 20],
children: [rect('child', 0, 0)],
}),
],
},
],
};
expect(getNodeVisualPosition(doc, DEFAULT_PAGE_ID, 'child')).toEqual({ x: 120, y: 60 });
});
it('preserves visual position when moving a layout-positioned child to root', () => {
const doc: PenDocument = {
version: '1.0.0',
children: [],
pages: [
{
id: DEFAULT_PAGE_ID,
name: 'Page 1',
children: [
frame('parent', {
x: 100,
y: 50,
layout: 'vertical',
padding: [10, 20],
children: [rect('child', 0, 0)],
}),
rect('root-sibling', 300, 300),
],
},
],
};
const movedChildren = moveNodePreservingVisualPosition(doc, DEFAULT_PAGE_ID, 'child', null, 1);
expect(movedChildren).toBeDefined();
const moved = findNodeInTree(movedChildren ?? [], 'child');
expect(moved?.x).toBe(120);
expect(moved?.y).toBe(60);
});
it('promotes a root frame to explicit clipContent when nesting it', () => {
const doc: PenDocument = {
version: '1.0.0',
children: [],
pages: [
{
id: DEFAULT_PAGE_ID,
name: 'Page 1',
children: [
frame('outer', {
x: 300,
y: 100,
children: [],
}),
frame('root-frame', {
x: 20,
y: 30,
cornerRadius: 16,
children: [rect('child', 0, 0)],
}),
],
},
],
};
const movedChildren = moveNodePreservingVisualPosition(
doc,
DEFAULT_PAGE_ID,
'root-frame',
'outer',
0,
);
const moved = findNodeInTree(movedChildren ?? [], 'root-frame') as PenNode | undefined;
expect(moved).toBeDefined();
expect(moved && 'clipContent' in moved ? moved.clipContent : undefined).toBe(true);
});
});

View file

@ -0,0 +1,61 @@
import { flattenToRenderNodes, premeasureTextHeights, resolveRefs } from '@zseven-w/pen-renderer';
import type { FrameNode, PenDocument, PenNode } from '@/types/pen';
import { getDefaultTheme, resolveNodeForCanvas } from '@/variables/resolve-variables';
import {
findNodeInTree,
findParentInTree,
getActivePageChildren,
getAllChildren,
insertNodeInTree,
removeNodeFromTree,
} from './document-tree-utils';
export function getNodeVisualPosition(
doc: PenDocument,
activePageId: string | null,
nodeId: string,
): { x: number; y: number } | undefined {
const pageChildren = getActivePageChildren(doc, activePageId);
const allNodes = getAllChildren(doc);
const resolved = resolveRefs(pageChildren, allNodes);
const variables = doc.variables ?? {};
const theme = getDefaultTheme(doc.themes);
const variableResolved = resolved.map((node) => resolveNodeForCanvas(node, variables, theme));
const measured = premeasureTextHeights(variableResolved);
const renderNode = flattenToRenderNodes(measured).find((rn) => rn.node.id === nodeId);
return renderNode ? { x: renderNode.absX, y: renderNode.absY } : undefined;
}
export function moveNodePreservingVisualPosition(
doc: PenDocument,
activePageId: string | null,
id: string,
newParentId: string | null,
index?: number,
): PenNode[] | undefined {
const pageChildren = getActivePageChildren(doc, activePageId);
const node = findNodeInTree(pageChildren, id);
if (!node) return undefined;
const currentParent = findParentInTree(pageChildren, id);
const currentVisual = getNodeVisualPosition(doc, activePageId, id);
const parentVisual = newParentId
? getNodeVisualPosition(doc, activePageId, newParentId)
: { x: 0, y: 0 };
if (!currentVisual || !parentVisual) return undefined;
const movedNode = {
...node,
x: currentVisual.x - parentVisual.x,
y: currentVisual.y - parentVisual.y,
} as PenNode;
if (node.type === 'frame' && !currentParent && newParentId !== null) {
const movedFrame = movedNode as FrameNode;
if (movedFrame.clipContent !== true) {
movedFrame.clipContent = true;
}
}
return insertNodeInTree(removeNodeFromTree(pageChildren, id), newParentId, movedNode, index);
}

View file

@ -21,6 +21,7 @@ import {
setActivePageChildren,
getAllChildren,
} from './document-tree-utils';
import { moveNodePreservingVisualPosition } from './document-position-utils';
type SetState = {
(partial: Partial<{ document: PenDocument; isDirty: boolean }>): void;
@ -53,7 +54,12 @@ interface NodeActions {
addNode: (parentId: string | null, node: PenNode, index?: number) => void;
updateNode: (id: string, updates: Partial<PenNode>) => void;
removeNode: (id: string) => void;
moveNode: (id: string, newParentId: string | null, index: number) => void;
moveNode: (
id: string,
newParentId: string | null,
index: number,
options?: { preserveAbsolutePosition?: boolean },
) => void;
reorderNode: (id: string, direction: 'up' | 'down') => void;
toggleVisibility: (id: string) => void;
toggleLock: (id: string) => void;
@ -97,13 +103,31 @@ export function createNodeActions(
);
},
moveNode: (id, newParentId, index) => {
moveNode: (id, newParentId, index, options) => {
const state = get();
const children = _children(state);
const node = findNodeInTree(children, id);
if (!node) return;
const withoutNode = removeNodeFromTree(children, id);
const withNode = insertNodeInTree(withoutNode, newParentId, deepCloneNode(node), index);
const withNode = options?.preserveAbsolutePosition
? (moveNodePreservingVisualPosition(
state.document,
useCanvasStore.getState().activePageId,
id,
newParentId,
index,
) ??
insertNodeInTree(
removeNodeFromTree(children, id),
newParentId,
deepCloneNode(node),
index,
))
: insertNodeInTree(
removeNodeFromTree(children, id),
newParentId,
deepCloneNode(node),
index,
);
mutateWithHistory(get, set, () => _setChildren(state.document, withNode));
},

View file

@ -40,7 +40,12 @@ interface DocumentStoreState {
addNode: (parentId: string | null, node: PenNode, index?: number) => void;
updateNode: (id: string, updates: Partial<PenNode>) => void;
removeNode: (id: string) => void;
moveNode: (id: string, newParentId: string | null, index: number) => void;
moveNode: (
id: string,
newParentId: string | null,
index: number,
options?: { preserveAbsolutePosition?: boolean },
) => void;
reorderNode: (id: string, direction: 'up' | 'down') => void;
toggleVisibility: (id: string) => void;
toggleLock: (id: string) => void;

View file

@ -4,6 +4,8 @@ export type {
GradientStop,
LinearGradientFill,
RadialGradientFill,
ImageOriginalSize,
ImageTransform,
ImageFill,
PenFill,
PenStroke,

View file

@ -18,7 +18,21 @@
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@zseven-w/pen-types": ["../../packages/pen-types/src/index.ts"],
"@zseven-w/pen-core": ["../../packages/pen-core/src/index.ts"],
"@zseven-w/pen-figma": ["../../packages/pen-figma/src/index.ts"],
"@zseven-w/pen-renderer": ["../../packages/pen-renderer/src/index.ts"],
"@zseven-w/pen-engine": ["../../packages/pen-engine/src/index.ts"],
"@zseven-w/pen-react": ["../../packages/pen-react/src/index.ts"],
"@zseven-w/pen-mcp": ["../../packages/pen-mcp/src/index.ts"],
"@zseven-w/pen-ai-skills": ["../../packages/pen-ai-skills/src/index.ts"],
"@zseven-w/pen-ai-skills/style-guide": [
"../../packages/pen-ai-skills/src/style-guide/index.ts"
],
"@zseven-w/pen-ai-skills/_generated/style-guide-registry": [
"../../packages/pen-ai-skills/src/_generated/style-guide-registry.ts"
]
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
"author": {
@ -18,7 +18,7 @@
"type": "module",
"main": "out/desktop/main.cjs",
"scripts": {
"dev": "cd apps/web && bun --bun vite dev --port 3000",
"dev": "bun run apps/web/dev.ts",
"build": "cd apps/web && bun --bun vite build",
"preview": "cd apps/web && bun --bun vite preview",
"test": "cd apps/web && bun --bun vitest run --passWithNoTests",

@ -1 +1 @@
Subproject commit e07d743a16ccab79956ff9f5bce5b0729ac9e701
Subproject commit 2c48403f81f43b19c9f2b7a60aca83646ab5c5f4

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-acp",
"version": "0.7.1",
"version": "0.7.2",
"description": "ACP (Agent Client Protocol) client for OpenPencil — connect to external ACP agents",
"files": [
"src"

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-ai-skills",
"version": "0.7.1",
"version": "0.7.2",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-ai-skills",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"

View file

@ -1,8 +1,6 @@
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import matter from 'gray-matter';
// @ts-expect-error js-yaml is installed without ambient types in this workspace.
import yaml from 'js-yaml';
import type { Plugin } from 'vite';
import type { SkillMeta } from './src/engine/types';
@ -11,20 +9,6 @@ const STYLE_GUIDES_DIR = 'skills/style-guides';
const OUTPUT_DIR = 'src/_generated';
const OUTPUT_FILE = 'skill-registry.ts';
const STYLE_GUIDE_OUTPUT_FILE = 'style-guide-registry.ts';
const MATTER_OPTIONS = {
engines: {
yaml: {
parse(source: string) {
const parsed = yaml.load(source);
return parsed && typeof parsed === 'object' ? parsed : {};
},
stringify(value: unknown) {
return yaml.dump(value);
},
},
},
};
function scanMarkdownFiles(dir: string, excludeDirs: string[] = []): string[] {
const results: string[] = [];
if (!existsSync(dir)) return results;
@ -47,7 +31,7 @@ function scanMarkdownFiles(dir: string, excludeDirs: string[] = []): string[] {
function parseFrontmatter(filePath: string): { meta: SkillMeta; content: string } | null {
const raw = readFileSync(filePath, 'utf-8');
const { data, content } = matter(raw, MATTER_OPTIONS);
const { data, content } = matter(raw);
if (!data.name || !data.phase) {
console.warn(

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-core",
"version": "0.7.1",
"version": "0.7.2",
"description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-core",
"bugs": {

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-engine",
"version": "0.7.1",
"version": "0.7.2",
"description": "Headless design engine for OpenPencil — zero framework dependencies",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-engine",
"bugs": {

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-figma",
"version": "0.7.1",
"version": "0.7.2",
"description": "Figma .fig file parser and converter for OpenPencil",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-figma",
"bugs": {

View file

@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { mapFigmaFills } from './figma-fill-mapper';
describe('mapFigmaFills', () => {
it('preserves non-identity image transforms for cropped image fills', () => {
const fills = mapFigmaFills([
{
type: 'IMAGE',
visible: true,
opacity: 1,
imageScaleMode: 'STRETCH',
originalImageWidth: 2644,
originalImageHeight: 1696,
transform: {
m00: 0.9682299494743347,
m01: 0,
m02: 0.019307976588606834,
m10: 0,
m11: 0.9433962106704712,
m12: 0.041042111814022064,
},
image: {
hash: Uint8Array.from([
0x1a, 0x5f, 0x26, 0xdd, 0xcd, 0x1f, 0xf2, 0xdb, 0x35, 0x95, 0xb8, 0x45, 0xfb, 0xe9,
0xa1, 0x77, 0x1c, 0x46, 0xae, 0x3f,
]),
},
},
]);
expect(fills).toEqual([
{
type: 'image',
url: '__hash:1a5f26ddcd1ff2db3595b845fbe9a1771c46ae3f',
mode: 'stretch',
originalSize: {
width: 2644,
height: 1696,
},
opacity: 1,
transform: {
m00: 0.9682299494743347,
m01: 0,
m02: 0.019307976588606834,
m10: 0,
m11: 0.9433962106704712,
m12: 0.041042111814022064,
},
},
]);
});
it('drops identity image transforms to avoid noisy documents', () => {
const fills = mapFigmaFills([
{
type: 'IMAGE',
visible: true,
imageScaleMode: 'STRETCH',
transform: {
m00: 1,
m01: 0,
m02: 0,
m10: 0,
m11: 1,
m12: 0,
},
image: {
dataBlob: 42,
},
},
]);
expect(fills).toEqual([
{
type: 'image',
url: '__blob:42',
mode: 'stretch',
opacity: undefined,
transform: undefined,
},
]);
});
});

View file

@ -1,7 +1,9 @@
import type { FigmaPaint, FigmaMatrix } from './figma-types';
import type { PenFill } from '@zseven-w/pen-types';
import type { ImageOriginalSize, ImageTransform, PenFill } from '@zseven-w/pen-types';
import { figmaColorToHex } from './figma-color-utils';
const IMAGE_TRANSFORM_EPSILON = 0.000001;
/**
* Convert Figma fillPaints (internal format) to PenFill[].
*/
@ -74,6 +76,8 @@ function mapSingleFill(paint: FigmaPaint): PenFill | null {
type: 'image',
url,
mode: mapScaleMode(paint.imageScaleMode),
originalSize: normalizeOriginalSize(paint.originalImageWidth, paint.originalImageHeight),
transform: normalizeImageTransform(paint.transform),
opacity: paint.opacity,
};
}
@ -91,6 +95,45 @@ function gradientAngleFromTransform(m: FigmaMatrix): number {
return Math.round(90 - mathAngle);
}
function normalizeOriginalSize(width?: number, height?: number): ImageOriginalSize | undefined {
if (
typeof width !== 'number' ||
typeof height !== 'number' ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
function normalizeImageTransform(transform?: FigmaMatrix): ImageTransform | undefined {
if (!transform) return undefined;
if (
Math.abs(transform.m00 - 1) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m01) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m02) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m10) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m11 - 1) <= IMAGE_TRANSFORM_EPSILON &&
Math.abs(transform.m12) <= IMAGE_TRANSFORM_EPSILON
) {
return undefined;
}
return {
m00: transform.m00,
m01: transform.m01,
m02: transform.m02,
m10: transform.m10,
m11: transform.m11,
m12: transform.m12,
};
}
function mapScaleMode(mode?: string): 'stretch' | 'fill' | 'fit' {
switch (mode) {
case 'FIT':

View file

@ -62,6 +62,8 @@ export interface FigmaPaint {
transform?: FigmaMatrix;
image?: FigmaImage;
imageScaleMode?: 'STRETCH' | 'FIT' | 'FILL' | 'TILE';
originalImageWidth?: number;
originalImageHeight?: number;
}
export type FigmaEffectType =

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-mcp",
"version": "0.7.1",
"version": "0.7.2",
"description": "MCP server, document manager, and tools for OpenPencil",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-mcp",
"bugs": {

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-react",
"version": "0.7.1",
"version": "0.7.2",
"description": "React UI SDK for OpenPencil — hooks, components, and state bridges for pen-engine",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-react",
"bugs": {

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-renderer",
"version": "0.7.1",
"version": "0.7.2",
"description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-renderer",
"bugs": {

View file

@ -274,4 +274,35 @@ describe('flattenToRenderNodes — dimension consistency', () => {
expect(t1.clipRect!.h).toBe(rootRN.absH);
expect(t1.clipRect!.w).toBe(rootRN.absW);
});
it('nested frame with clipContent clips its descendants using its own bounds/radius', () => {
const root = frame({
id: 'root',
width: 400,
height: 400,
children: [
frame({
id: 'card',
x: 40,
y: 50,
width: 200,
height: 120,
cornerRadius: 16,
clipContent: true,
children: [text('inner', 'Nested content', { width: 'fill_container' as any })],
}),
],
});
const nodes = flattenToRenderNodes([root]);
const card = nodes.find((rn) => rn.node.id === 'card')!;
const inner = nodes.find((rn) => rn.node.id === 'inner')!;
expect(inner.clipRect).toBeDefined();
expect(inner.clipRect!.x).toBe(card.absX);
expect(inner.clipRect!.y).toBe(card.absY);
expect(inner.clipRect!.w).toBe(card.absW);
expect(inner.clipRect!.h).toBe(card.absH);
expect(inner.clipRect!.rx).toBe(16);
});
});

View file

@ -241,10 +241,12 @@ export function flattenToRenderNodes(
const positioned =
layout && layout !== 'none' ? computeLayoutPositions(resolved, children) : children;
// Clipping — only clip for root frames (artboard behavior).
// Clipping — root frames always clip like artboards. Nested containers
// clip only when clipContent is enabled.
let childClip = clipCtx;
const isRootFrame = node.type === 'frame' && depth === 0;
if (isRootFrame) {
const explicitClip = 'clipContent' in resolved && resolved.clipContent === true;
if (isRootFrame || explicitClip) {
const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0;
const cr = Math.min(crRaw, nodeH / 2);
childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr };

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-sdk",
"version": "0.7.1",
"version": "0.7.2",
"description": "OpenPencil SDK — parse, manipulate, and generate code from .op design files",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-sdk",
"bugs": {

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/pen-types",
"version": "0.7.1",
"version": "0.7.2",
"description": "Type definitions for OpenPencil document model",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-types",
"bugs": {

View file

@ -5,6 +5,8 @@ export type {
GradientStop,
LinearGradientFill,
RadialGradientFill,
ImageOriginalSize,
ImageTransform,
ImageFill,
PenFill,
PenStroke,

View file

@ -44,6 +44,7 @@ export interface PenNodeBase {
type: PenNodeType;
name?: string;
role?: string; // semantic role for AI generation ("button", "card", "heading", etc.)
explain?: string; // explanatory semantic layer for the AI consumer view
x?: number;
y?: number;
rotation?: number;

View file

@ -16,6 +16,7 @@ export type BlendMode =
export interface SolidFill {
type: 'solid';
color: string; // #RRGGBB or #RRGGBBAA
explain?: string;
opacity?: number;
blendMode?: BlendMode;
}
@ -29,6 +30,7 @@ export interface LinearGradientFill {
type: 'linear_gradient';
angle?: number;
stops: GradientStop[];
explain?: string;
opacity?: number;
blendMode?: BlendMode;
}
@ -39,14 +41,32 @@ export interface RadialGradientFill {
cy?: number;
radius?: number;
stops: GradientStop[];
explain?: string;
opacity?: number;
blendMode?: BlendMode;
}
export interface ImageOriginalSize {
width: number;
height: number;
}
export interface ImageTransform {
m00: number;
m01: number;
m02: number;
m10: number;
m11: number;
m12: number;
}
export interface ImageFill {
type: 'image';
url: string;
mode?: 'fill' | 'fit' | 'crop' | 'tile' | 'stretch';
originalSize?: ImageOriginalSize;
transform?: ImageTransform;
explain?: string;
opacity?: number;
exposure?: number; // -100 to 100
contrast?: number; // -100 to 100