Compare commits

...

61 Commits

Author SHA1 Message Date
snomiao
d4e5317e79 fix(agent): hide FAB in screenshots, SSR guard, and tail event forwarding
- Inject CSS to hide agent UI in all E2E screenshot tests; tag agent
  terminal tests with @agent to opt out of the hide (fixes 170+ CI
  screenshot failures caused by the FAB overlapping #graph-canvas)
- Guard localStorage access in resolveProbeBase() behind a typeof-window
  check so it doesn't throw in SSR/unit-test contexts (Copilot comment)
- Rebase onto sno-frontend-preview (3 upstream commits: API key test
  button, connection panel refactor, pr-badge i18n fix)
- Implement comfy-ai tail: useLocalBridge now watches agentStore.messages
  and agentStore.isStreaming, emitting SpaEvent frames to the daemon so
  `comfy-ai tail` streams live session activity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:43:34 +09:00
snomiao
8c6fdeae37 feat(agent): local bridge composable + pairing UI in settings
- useLocalBridge: WS singleton connects to ws://127.0.0.1:7437/spa on
  AgentRoot mount, relays send/eval/abort/ping from CLI to SPA runtime
- useBridgeStatus: read-only bridge state (connected, activePairCode)
  readable from any component without lifecycle side-effects
- AgentSettings: 'Local agent bridge' section shows connection dot +
  status text; 'Generate pair code' button sends pair-request to daemon
  and shows the comfy-ai pair ... command to run in terminal; clears
  automatically when CLI claims the code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:39:21 +09:00
snomiao
270ab4ffcb feat(agent): default model gpt-5.5 + get API key links in settings
Add links to OpenAI and OpenRouter key pages below the API key input so
users know where to obtain a key and how to configure the base URL for
non-OpenAI providers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:39:21 +09:00
snomiao
1feebde790 fix(agent): codex review fixes
- agentStore: useIDBKeyval is async; the previous code seeded `messages`
  with an empty snapshot then enabled the write-back watcher immediately,
  so any send before the IDB read settled would clobber stored history
  with []. Gate the watcher on `persisted.isFinished` and merge the
  hydrated entries when they arrive.
- FoldablePanel: dropping `/` from the shell-operator regex. The
  attachment flow prefills the composer with paths like /input/foo.png,
  and the previous regex routed those to direct exec instead of the LLM.
- coreutils grep: drain stdin eagerly so the no-match case can return
  exit code 1. The previous generator-based version always returned 0,
  breaking `grep ... && ...` and `grep ... || ...` decision flows the
  agent relies on.
- registrySearch: stop advertising a non-existent `install-pack`
  command. Direct the user to ComfyUI-Manager UI instead until a real
  install-pack shell command exists.
- agentTerminal.spec: rewrite for the textarea-based panel; the old
  `.xterm-rows` / `.xterm-helper-textarea` locators no longer resolve.
  Drops the Tab-completion test (useCompletion was deleted) and adds a
  Ctrl+O fold/unfold smoke test.
2026-05-02 17:39:21 +09:00
snomiao
68b477dbbc feat(agent): inline prompt — input flows as next scrollback line
Previously the textarea was a separate footer with its own border/bg,
breaking the terminal illusion. Move the prompt+input INTO the
scrollable container so it visually flows as the next line of
scrollback — same font, same color scheme as the user-message blocks,
no border, no background.

- Pending-asset chips now render inline just above the prompt within
  the same scroll container (drops the heavy footer chrome).
- Click any blank space in the scrollback focuses the input — like a
  real terminal where typing always lands at the prompt regardless of
  scroll position. Clicks on actual messages are left alone so text
  selection still works.
- The prompt scrolls with content; auto-scroll-to-bottom keeps it in
  view when new messages arrive.
2026-05-02 17:39:21 +09:00
snomiao
46f4ee9e9f feat(agent): foldable Vue terminal with Ctrl+O toggle
Replaces the xterm.js scrollback with a Vue-native terminal that can
fold tool-call output. Tool calls collapse to a one-line summary by
default — "$ <cmd>   N lines, exit M" with green/red exit-code
colour — and Ctrl+O (or ⌘+O) toggles fold/unfold across every block at
once. Click the block header to toggle a single tool call.

Why this trade-off:
- xterm.js is a character grid; folding requires a parallel logical
  model and a full re-render on every toggle. Vue components track
  semantic blocks natively, so fold state is just reactive data.
- Side benefits: per-block text selection/copy, native theming via
  Tailwind tokens, no ANSI cursor math, no @xterm/* deps in the agent
  bundle, easier to extend (per-tool icons, inline images, links).

Implementation notes:
- store.AgentMessage gains an optional 'tool' meta field carrying
  script/stdout/stderr/exitCode so the renderer doesn't have to parse
  the synthesized text summary back into structure.
- Global keydown listener with capture:true preempts the browser's
  default Open-File dialog before Ctrl+O reaches it.
- Drops src/agent/ui/XtermPanel.vue, useXtermReadline.ts, and
  shell/useCompletion.ts. The xterm package is still used elsewhere
  in the codebase (logs/CommandTerminal), so no dep removal.
2026-05-02 17:39:21 +09:00
snomiao
775f4b28ba feat(agent): registry + community code search commands
New shell commands so the agent can find custom nodes the user has not
yet installed, instead of giving up when local node-search is empty:

- node-search-registry <q>  fuzzy node-class search across all
                            published packs (Comfy Registry)
- pack-search <q>           pack-name/description search
- pack-info <pack_id>       list every node-class a pack provides
- comfy-codesearch <q>      full-text source search across the WHOLE
                            public ComfyUI community via cs.comfy.org
                            (catches unregistered repos)

Discovery escalation chain in the system prompt is now:
  node-search → node-search-registry → pack-search/info → comfy-codesearch
The agent must walk down before claiming a node is missing.
2026-05-02 17:39:21 +09:00
snomiao
edd2c3a248 feat(agent): read-before-write guardrail + don't-stop-too-early
Layer 1.5 guardrail: any script touching mutating commands (add-node,
connect, set-widget, clear-workflow, save-as, …) is rejected by the
shell runtime until at least one canvas-observing read (graph, node-
list, active-workflow, …) has run in the same turn. Stops the agent
from blindly overwriting an existing workflow it never inspected.

Auto-continue tweaks:
- PROMISSORY_PATTERN extended to catch stop-early phrases ("here's a
  plan", "next, I'll", "we can also add", "to build/finish/complete")
  that surfaced as premature exits in real sessions.
- MAX_AUTOCONTINUE 3 → 6 so multi-step builds (load → encode → sample →
  decode → save) finish without prematurely punting back to the user.
2026-05-02 17:39:21 +09:00
snomiao
5afcb892b8 feat(agent): per-session log files in dev
Vite dev plugin /__agent-log now writes one file per browser session
(tmp/agent-logs/<date>-<sessionId>.jsonl) instead of mixing all sessions
into a single daily log. The browser logger already attaches sessionId
to every entry; this just routes by it on the server side.

Restores build/plugins/agentLog.ts that was lost in an earlier squash.
2026-05-02 17:39:21 +09:00
snomiao
87b3f13f87 feat(agent): experimental in-browser ComfyAI agent
Squashes 60 incremental agent commits into one cohesive change after
prior history accumulated 119 unrelated upstream commits during a
bad rebase. Working tree restored from sno-agent@aec9f9380; new deps
added freshly (no stale lockfile entries); changeTracker API call
sites updated for current sno-frontend-preview (captureCanvasState →
checkState).

What's in this commit:

UI: src/agent/ui/{AgentRoot,AgentFab,XtermPanel,AgentSettings,
useXtermReadline}.vue
- Floating ComfyAI button + draggable xterm panel toggled via 'c'
  keybind; auto-focuses the terminal helper textarea on open.
- xterm-driven readline with Tab completion, Shift+Enter newline,
  Ctrl-A/E/U/K/L/C, history navigation, multiline buffers.
- Liquid-glass theme using Comfy design tokens.
- Settings panel auto-opens when no API key set; compact 3-field
  layout (API base / API key / model) + collapsed advanced section.

Session loop: src/agent/llm/session.ts, composables/useAgentSession.ts
- Vercel AI SDK streamText with run_shell as the only tool.
- IndexedDB-persisted message history (300-message cap), replays
  on reopen with a 'previous session' divider.
- Programmatic guardrails: PROMISSORY_PATTERN auto-continue,
  silent-fail auto-continue, fragile-shell-idiom blocklist
  (Layer 1), definitive-claim verifier registry (Layer 2 — orphans,
  missing-models, queue-state, pre-refusal, punt-to-user).
- Configurable baseURL for OpenRouter / local LLM proxies; default
  gpt-5.4 via OpenAI.

Shell runtime: src/agent/shell/
- POSIX-ish parser (shell-quote based), AsyncIterable pipes,
  redirection, &&/||/; sequencing.
- VFS: in-memory /tmp + UserdataVFS-backed /workflows.
- Coreutils + Comfy.* command dispatch + run-js fallback.

Commands: src/agent/shell/commands/
- comfy/comfyNs: registered command discovery + namespace dispatch.
- workflow: save-as / new-workflow / rename-workflow / clear-workflow
  --force / set-subgraph-{desc,aliases} (modal-bypass equivalents).
- nodeOps: node-search, add-node (smart placement), connect (auto-
  layout on link), disconnect, remove-node, layout, align-nodes,
  distribute-nodes, select, get-widget, toggle-panel.
- graph/state/execution/sweep: introspection + queue + sweep helpers.
- templates / images / install / validate / see: template loading,
  output→input copy, Manager model install, Gemini canvas vision.

Tests: browser_tests/tests/agentTerminal.spec.ts, plus
src/agent/**/*.test.ts unit tests.

Docs: docs/adr/0009-frontend-only-agent-and-local-agent-bridge.md
records the architectural choice + rejected alternatives.

i18n: minimal en additions in src/locales/en/main.json for
agent.{fab,input,panel,settings} namespaces.

Deps added: @ai-sdk/openai, ai, zod, shell-quote, idb-keyval,
@xterm/xterm, @xterm/addon-fit, es-toolkit, @types/shell-quote.

PR #11547 — experimental, draft, expect breaking changes.
Preview: https://pr-11547.comfy-ui.pages.dev/
2026-05-02 17:39:21 +09:00
snomiao
eba055befe fix(pr-badge): avoid i18n interpolation for backend URL to prevent HTML escaping
escapeParameter: true in i18n.ts caused slashes in URLs to render as
&#x2F; in the popover tooltip. Concatenate the label and URL directly
instead of passing the URL through t() interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 13:39:21 +09:00
snomiao
4c8e5ad797 refactor(connect): move API key input into connection status section
Contextually show the API key field only after backend is tested and
cloud API is detected, co-located with the cloud API info and the
"Generate an API key" link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:11:18 +09:00
snomiao
d3bd6f9f12 feat(connect): add API key test button and hide when --disable-api-nodes
- Add Test button for Comfy API Key input (validates against /customers endpoint)
- Show ✓/✗ status after test
- Update hint text: "Only needed for cloud-API nodes"
- Hide API key section when backend reports --disable-api-nodes in argv
- Show notice when API nodes are disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 07:40:09 +09:00
snomiao
a785f72aa0 feat(connect): add optional API key input to connection panel
Users can now enter their Comfy API key on /connect before clicking
"Connect & Open ComfyUI". The key is saved to localStorage (comfy_api_key)
so it's picked up automatically after connecting — no need to re-enter it
in Settings → API Key.

Existing key is pre-filled from localStorage if already set.
Empty field on connect leaves any existing stored key unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 06:43:05 +09:00
snomiao
6130880992 fix(ci): use prod cloud API config for preview builds
Preview builds were using stagingapi.comfy.org (USE_PROD_CONFIG=false
default), causing a constant cloud environment mismatch warning for
every user who followed the quick-start guide — their local ComfyUI
backend defaults to api.comfy.org (production).

Setting USE_PROD_CONFIG=true aligns the preview with the default backend
behavior so the mismatch warning only fires when the user deliberately
passes --comfy-api-base=staging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 06:38:17 +09:00
snomiao
fb46996002 fix(topbar-badge): remove redundant tooltip text from full-mode popover
The tooltip string (with HTML-escaped URLs and dot-separated fields) was
showing inside the popover alongside the cleaner popoverLinks section.
Remove it — the links already convey all the same info without the HTML
entity artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 05:15:43 +09:00
snomiao
bf11a90cd8 fix(topbar-badge): add popover to full display mode when popoverLinks set
Full mode only had v-tooltip (plain text on hover). When popoverLinks is
present, the badge now shows a clickable Popover with links, matching the
behavior of icon-only and compact modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 04:16:59 +09:00
snomiao
0c4861162d feat(pr-badge): show short commit hash in badge text alongside PR number
Badge now shows "PR #11118 · abc12345" so reviewers can see the exact
commit at a glance without opening the popover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 04:03:37 +09:00
snomiao
17f5bde180 feat(topbar-badge): add popoverLinks for clickable PR/commit/connect links
Extends TopbarBadge with a popoverLinks field that renders as <a> tags
in the popover. The PR preview badge now shows clickable links to the
GitHub PR page, author profile, commit, and a "Configure backend →"
link that re-opens /connect to change the backend URL at any time.

Also fixes prPreviewBadges backendUrl to read from localStorage directly
(shows '—' when no remote backend configured, instead of the CF Pages
domain itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 03:56:57 +09:00
snomiao
d840020427 fix: unexport DEFAULT_CLOUD_API_BASE (knip unused export)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
1a993fc1dc feat(topbar): add PR preview badge with build provenance
- Add prPreviewBadges extension (active only when CI_PR_NUMBER is set):
  - Topbar warning badge showing "PR #XXXX", tooltip with author/commit/backend URL
  - About panel badges: clickable PR#, author (@handle), commit hash links
- Declare __CI_PR_NUMBER__, __CI_PR_AUTHOR__, __CI_BRANCH__, __CI_RUN_ID__,
  __CI_JOB_ID__, __COMFYUI_FRONTEND_COMMIT__ as global TS constants in vite-env.d.ts
- Extract resolveBackendCloudBase() to shared util at
  src/platform/connectionPanel/resolveBackendCloudBase.ts
- Add comfy_api_base as optional field in zSystemStats schema (companion to
  backend PR Comfy-Org/ComfyUI#13571)
- Add prPreview i18n keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
321c32463e feat(connection-panel): consume comfy_api_base from system_stats directly
When the backend exposes `system.comfy_api_base` (added in companion
backend PR), use it directly instead of parsing it out of `argv`.
Falls back to argv parsing for older backends that don't yet include
the field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
GitHub Action
0a441ab896 [automated] Apply ESLint and Oxfmt fixes 2026-04-27 09:07:05 +09:00
snomiao
cf01213235 feat(connection-panel): add copy buttons to command blocks and expand local-network guidance
- Add CopyCodeBlock component with copy-to-clipboard and 2s "copied" checkmark feedback
- Replace all static <code> blocks in ConnectionPanelView with CopyCodeBlock
- Pre-compute launch command strings in script to avoid HTML entity escaping in template
- Expand localAccess section with --listen command for multi-device LAN testing
- Add corsOriginNote explaining why the exact origin matters (not *)
- Add i18n keys: corsOriginNote, localAccessListenDescription, localAccessListenNote

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
61bcf0a8bc feat(connection-panel): add preview-build warning aside with provenance
Adds a prominent amber aside under the panel title showing:
- This is an in-flight PR build, UI may change rapidly, not for production
- Build provenance: PR number, commit hash, author (all linked to GitHub)
- Trust warning: do not connect a backend you care about unless you trust
  the PR author, since a malicious frontend can read or modify any
  workflow / model / output on the connected backend.

Wires `CI_PR_AUTHOR` from `github.event.pull_request.user.login` through
the build env into a new `__CI_PR_AUTHOR__` define.
2026-04-27 09:07:05 +09:00
snomiao
0b90645d87 feat(connection-panel): link to API key page for the backend's cloud target
When the backend reports a known Comfy Cloud API base (prod or staging via
`--comfy-api-base`), surface a link to the matching platform's API-keys page
(platform.comfy.org or stagingplatform.comfy.org). Custom/unknown bases hide
the link rather than guess a URL.
2026-04-27 09:07:05 +09:00
snomiao
8f60294f63 feat(connection-panel): detect backend cloud API base from system_stats argv
Parse `--comfy-api-base` out of `/system_stats` `system.argv` and surface the
backend's Comfy Cloud target in the connection panel. When it disagrees with
the frontend's build-time `getComfyApiBaseUrl()`, show an amber warning so
users can spot the mismatch before sign-in tokens get rejected.

Records the cleaner long-term path (add `comfy_api_base` to the backend's
`/features` endpoint) in docs/backend-cloud-api-base-feature-flag.md.
2026-04-27 09:07:05 +09:00
snomiao
da8be4dc6c ci(preview): use sanitized branch name for cloudflare alias
Aligns frontend preview with storybook/playwright deploy scripts which
already key the cloudflare branch alias on the sanitized git branch name.
Falls back to pr-$PR_NUMBER when BRANCH_NAME is unset.
2026-04-27 09:07:05 +09:00
snomiao
3fa9c4522a refactor(connection-panel): combine comfy-cli install and comfy install into one step
uv pip install puts comfy on PATH immediately, so both commands
chain with && in the same shell. Collapses 4 quick-start steps to 3
and keeps the shell flow linear for copy-paste.
2026-04-27 09:07:04 +09:00
snomiao
2b675d6b5c fix: install pnpm in deploy-and-comment job so wrangler can run
The deploy script uses `pnpm dlx wrangler` when wrangler isn't on
PATH, but the deploy-and-comment job runs on a bare ubuntu image
with no pnpm — all three retry attempts fail with `pnpm: command
not found`. Set up pnpm + Node via the same actions setup-frontend
uses.
2026-04-27 09:07:04 +09:00
snomiao
66072fc4a6 fix: import cn from @comfyorg/tailwind-utils directly
The @/utils/tailwindUtil shim was removed from main in #11453; CI
merge-commit builds fail for this branch. Switch the import to the
package that replaced it. Both paths resolve locally today, so the
change is a drop-in.
2026-04-27 09:07:04 +09:00
snomiao
5f612e19b2 feat(connection-panel): promote ComfyUI-Manager in install guide
comfy install bundles ComfyUI-Manager by default. Surface that in
the quick start, plus an explanatory aside on why Manager smooths
the UX (one-click missing-model/custom-node install). For the
manual python alt path, add the git clone step explicitly.
2026-04-27 09:07:04 +09:00
snomiao
eb1fe9d88a fix: align backend probe base with ComfyApi for subpath deploys 2026-04-27 09:07:04 +09:00
snomiao
51e77c65ad chore: ignore runtime-generated scheduled_tasks.lock 2026-04-27 09:07:04 +09:00
snomiao
324d20477e refactor: drop unused branch_name parameter from preview deploy script 2026-04-27 09:07:04 +09:00
snomiao
6fb9915b45 chore: use pnpm dlx wrangler instead of npm/npx to match repo policy 2026-04-27 09:07:04 +09:00
snomiao
fed451edac fix: use BASE_URL for post-connect reload to support subpath deploys 2026-04-27 09:07:04 +09:00
snomiao
3ee55dfa1e fix: move page padding out of BaseViewTemplate into ConnectionPanelView 2026-04-27 09:07:04 +09:00
snomiao
93073cc242 fix: guard against malformed remote backend URL in localStorage 2026-04-27 09:06:08 +09:00
snomiao
5ef07f09fa chore: remove TODO.md planning file from repo 2026-04-27 09:06:07 +09:00
snomiao
2d5d77f7db fix: localize build metadata labels via vue-i18n
Move hardcoded English strings (PR #, Version:, Commit:, etc.)
in buildLabel and buildTooltip to i18n keys for consistency
with the rest of the connection panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
ef4d8622fa fix: scope CORS command to current preview origin
Replace wildcard * with window.location.origin so the
displayed command only opens the backend to this deployment,
not every origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
dc2d8375fd fix: announce connection status to assistive technology
Add role="status" and aria-live="polite" to the connection
status section so screen readers announce test results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
bd0a10d7f0 test: clean up stubGlobal state between tests
vi.restoreAllMocks() does not undo vi.stubGlobal(), so stubs
like fetch and WebSocket leaked between tests. Add afterEach
with vi.unstubAllGlobals() and re-stub localStorage in
beforeEach for isolation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
5b75bb5bbf fix: use PR-scoped preview alias instead of branch normalization
Branch normalization collapsed distinct names (feature/foo,
feature_foo, feature-foo) to the same key, letting unrelated PRs
overwrite each other's preview. pr-$PR_NUMBER is unique per PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
8bdc850830 fix: add contents:read permission and serialize deploy comment jobs
comment-on-pr-start needs contents:read for checkout, and
deploy-and-comment must wait for it to avoid overwriting
the initial "Building..." comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
95d0f22906 test: sync default.json fixture with defaultGraph models metadata
The locale-export test loads default.json then reloads the page
(which uses defaultGraph.ts). Since defaultGraph.ts now embeds
models metadata in the CheckpointLoader properties, the fixture
must match to avoid a mismatch between the English and Chinese
exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
a53ea4dae2 fix: use exit 1 instead of return at top-level in deploy script
return is invalid outside a function/sourced-script context and
would mask the original wrangler installation failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
7ba5fcdf1a fix: use URL API for WebSocket URL construction in testWs
Parse base URL with new URL() instead of regex stripping so
paths/query/fragments in user-entered URLs cannot corrupt the
WebSocket endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
c176723bbc test: cover testConnection failure, URL normalization, and Connect button reveal
Adds three ConnectionPanelView tests for the previously uncovered branches:
- HTTP fetch failure renders an error message
- URLs entered without a protocol get http:// prepended
- Connect & Open ComfyUI button appears after a successful HTTP+WS test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
559001341a test: extract isBackendReachable from router and cover its branches
Codecov reported 0% coverage on the backend-probe logic embedded in
router.ts beforeEnter. The router module is hard to test in isolation
because importing it constructs the router and pulls in cloud/desktop
side effects. Extract the probe to a pure async function in
platform/connectionPanel/ and unit-test it: success, non-2xx, missing
system field, fetch rejection, trailing-slash normalization, and
unconfigured (same-origin) fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
b9c677f54c fix: embed model download metadata in defaultGraph CheckpointLoader
The hardcoded default workflow used on first load referenced
v1-5-pruned-emaonly-fp16.safetensors but had an empty properties object,
so the missing-model panel had no URL to offer for download. The version
in the workflow_templates package already includes properties.models with
the HuggingFace download URL — mirror it here so the Download button
appears when the checkpoint isn't installed locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
6304a60656 fix: don't use SPA route pathname as router base
When user lands directly on a SPA route like /connect, the previous logic
set the router base to /connect, causing the /connect route to map to
/connect/connect (and similarly for any deep-linked SPA route). A deploy
directory pathname always ends with /; SPA route pathnames don't. Use the
trailing slash as the discriminator: keep pathname as base when it ends
with / (reverse-proxy subpath case), otherwise fall back to BASE_URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
0b0a1076f4 fix: use grid instead of flex m-auto for BaseViewTemplate centering
Flexbox has a known gotcha where auto margins go negative when content
exceeds the container, pushing content off the top. CSS grid's
place-items-center handles overflow correctly — content centers when it
fits, aligns to top when it doesn't, and scrolls properly. Fixes /connect
still being unscrollable from the previous m-auto attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
d1968f2033 fix: allow BaseViewTemplate content to scroll when taller than viewport
Using items-center on a flex container with overflow-auto cuts off the
top of content that exceeds the viewport height because flex centering
doesn't interact with scrolling. Replace with an m-auto inner wrapper,
which still centers short content but lets tall content flow naturally
and scroll. Fixes the long /connect panel being unscrollable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
f129dc2aeb feat: recommend uv for Python setup in connection panel
Users without Python installed were getting stuck on pip install comfy-cli.
Restructure the Quick Start to install uv first (which is cross-platform,
a single-line shell install, and doesn't require Python itself to be
present). Then use uv pip install comfy-cli --system. Keep the plain
pip path in the collapsible Alternative section with a note about the
Python 3.10+ requirement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
1a4b766fb7 ci: prefer branch alias URL over hash URL in preview comment
Wrangler outputs both a unique-per-deployment hash URL and a stable
branch alias URL. The previous regex picked the first match which was
the hash, changing every deploy. Use the branch alias when present so
the PR comment link stays stable and human-readable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
3e8022c0d1 test: migrate ConnectionPanelView to @testing-library/vue
@vue/test-utils was removed from main as part of the test framework
migration. Migrate this test file to @testing-library/vue with
userEvent and Testing Library queries (getByRole, getByDisplayValue,
getByText) to satisfy the testing-library/no-node-access rule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
cb696d8426 fix: separate origin from base path so remote backend WS URL is valid
Previously, when a remote backend URL was set via the connection panel,
api_base contained the full origin and path, causing the WebSocket URL
to be malformed: wss://127.0.0.1:8188http://127.0.0.1:8188/ws.

Now we keep api_host/api_base as just host/path and use a separate
remoteOrigin field that gets prepended in apiURL/fileURL/internalURL.
The WebSocket protocol is also now derived from the remote backend URL
when set, instead of always copying from the page protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
3720ba3829 feat: auto-redirect to /connect when backend unreachable, add comfy-cli guide
When deployed to static hosting (Cloudflare Pages), the frontend now
detects that no backend is available and redirects to /connect instead
of hanging on "Loading ComfyUI". The connection panel includes comfy-cli
quick start guide, connection tester, and "Connect & Go" button.
API requests are routed to the user-configured remote backend URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
cdc834705a feat: deploy frontend preview to Cloudflare Pages with ConnectionPanel
- Add CI workflow to build and deploy to comfy-ui.pages.dev on every push/PR
- Add deploy script with auto PR comments following storybook pattern
- Add ConnectionPanelView at /connect with backend URL config, HTTP/WS test, CLI guide, build info
- Inject CI metadata (branch, PR#, run ID, job ID) as build-time defines
- Add i18n strings, route, unit tests (8/8 passing)

Amp-Thread-ID: https://ampcode.com/threads/T-019d7738-d170-7409-8699-23a55d8ad5e7
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 09:06:06 +09:00
83 changed files with 11838 additions and 81 deletions

147
.github/workflows/ci-deploy-preview.yaml vendored Normal file
View File

@@ -0,0 +1,147 @@
# Description: Builds ComfyUI frontend and deploys previews to Cloudflare Pages
name: 'CI: Deploy Preview'
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
./scripts/cicd/pr-preview-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"starting"
# Build frontend for all PRs and pushes
build:
runs-on: ubuntu-latest
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build frontend
env:
FRONTEND_COMMIT_HASH: ${{ github.sha }}
CI_BRANCH: ${{ github.head_ref || github.ref_name }}
CI_PR_NUMBER: ${{ github.event.pull_request.number || '' }}
CI_PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }}
CI_RUN_ID: ${{ github.run_id }}
CI_JOB_ID: ${{ github.job }}
USE_PROD_CONFIG: 'true'
run: pnpm build
- name: Set job status
id: job-status
if: always()
run: |
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
- name: Get workflow URL
id: workflow-url
if: always()
run: |
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
- name: Upload build artifact
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v6
with:
name: dist
path: dist/
retention-days: 7
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [comment-on-pr-start, build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Download build artifact
if: needs.build.outputs.conclusion == 'success'
uses: actions/download-artifact@v7
with:
name: dist
path: dist
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
- name: Deploy preview and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ needs.build.outputs.conclusion }}
WORKFLOW_URL: ${{ needs.build.outputs.workflow-url }}
BRANCH_NAME: ${{ github.head_ref }}
run: |
./scripts/cicd/pr-preview-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"completed"
# Deploy to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build frontend
env:
FRONTEND_COMMIT_HASH: ${{ github.sha }}
CI_BRANCH: ${{ github.ref_name }}
CI_RUN_ID: ${{ github.run_id }}
CI_JOB_ID: ${{ github.job }}
run: pnpm build
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
pnpm dlx wrangler@^4.0.0 pages deploy dist \
--project-name=comfy-ui \
--branch=main

3
.gitignore vendored
View File

@@ -99,4 +99,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.claude/scheduled_tasks.lock

View File

@@ -119,7 +119,15 @@
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
],
"properties": {},
"properties": {
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
}
],

View File

@@ -518,6 +518,16 @@ export const comfyPageFixture = base.extend<{
await comfyPage.setup()
// Hide agent UI in all tests except those explicitly testing the agent.
// The FAB is positioned over the canvas viewport, which would cause
// unrelated screenshot tests to fail.
if (!testInfo.tags.includes('@agent')) {
await page.addStyleTag({
content:
'[data-testid="agent-fab"],[data-testid="agent-panel"]{display:none!important}'
})
}
if (isVueNodes) {
await comfyPage.vueNodes.waitForNodes()
}

View File

@@ -0,0 +1,162 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
/**
* E2E coverage for the in-browser agent terminal (AgentFab + FoldablePanel).
*
* The panel is now a Vue-native scrollback (no xterm.js), so the tests
* target the plain DOM directly: the input is a `<textarea>` inside
* `[data-testid="agent-terminal"]`, and the scrollback lives in the same
* container as a list of message blocks. We exercise the deterministic
* shell surface — typing into the textarea runs commands directly through
* the runtime, which is what the LLM ends up calling via `run_shell`.
*/
async function openPanel(comfyPage: ComfyPage): Promise<void> {
const fab = comfyPage.page.getByTestId('agent-fab')
await expect(fab).toBeVisible()
await fab.click()
await expect(comfyPage.page.getByTestId('agent-panel')).toBeVisible()
}
async function readTerminalText(comfyPage: ComfyPage): Promise<string> {
return await comfyPage.page.getByTestId('agent-terminal').innerText()
}
async function typeAndEnter(comfyPage: ComfyPage, text: string): Promise<void> {
const input = comfyPage.page.getByTestId('agent-terminal').locator('textarea')
await input.focus()
await comfyPage.page.keyboard.type(text)
await comfyPage.page.keyboard.press('Enter')
}
test.describe('Agent terminal', { tag: ['@ui', '@agent'] }, () => {
test('FAB opens the panel and shows the COMFY-AI title + prompt', async ({
comfyPage
}) => {
await openPanel(comfyPage)
await expect(comfyPage.page.getByTestId('agent-panel-title')).toHaveText(
'COMFY-AI'
)
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/comfy>/)
})
test('Clicking the FAB again closes the panel', async ({ comfyPage }) => {
await openPanel(comfyPage)
await comfyPage.page.getByTestId('agent-fab').click()
await expect(comfyPage.page.getByTestId('agent-panel')).toBeHidden()
})
test('Enter submits; help command lists built-ins', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'help')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/run-js|cmd-list|comfy/)
})
test('Shift+Enter inserts a literal newline (no submit)', async ({
comfyPage
}) => {
await openPanel(comfyPage)
const input = comfyPage.page
.getByTestId('agent-terminal')
.locator('textarea')
await input.focus()
await comfyPage.page.keyboard.type('echo one')
await comfyPage.page.keyboard.press('Shift+Enter')
await comfyPage.page.keyboard.type('echo two')
// Single submission should run BOTH lines as one multi-line script.
await comfyPage.page.keyboard.press('Enter')
const out = await readTerminalText(comfyPage)
expect(out).toContain('one')
expect(out).toContain('two')
})
test('coreutils: pwd / echo', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'pwd')
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/^\//m)
await typeAndEnter(comfyPage, 'echo hello world')
await expect
.poll(() => readTerminalText(comfyPage))
.toContain('hello world')
})
test('comfy namespace lists subcommands', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'comfy')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/ComfyUI command namespace/)
})
test('run-js evaluates in the page scope', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'run-js return 1 + 2')
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/\b3\b/)
})
test('graph summary reports node count for the active graph', async ({
comfyPage
}) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'graph summary')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/node|count|nodes/i)
})
test('queue-status command returns output', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'queue-status')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/running|pending|queue/i)
})
test('active-workflow reports path / state', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'active-workflow')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/path|modified|persisted|none/i)
})
test('pipe: echo foo | wc -c emits a byte count', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'echo foo | wc -c')
// "foo\n" = 4 bytes
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/\b4\b/)
})
test('unknown command surfaces an error', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'definitely-not-a-real-command-xyz')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/not found|unknown|no such/i)
})
test('Ctrl+O folds and unfolds tool blocks', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'graph summary')
// Tool blocks default to folded — body shouldn't be visible yet.
const panel = comfyPage.page.getByTestId('agent-panel')
await expect(
panel.locator('button:has-text("graph summary")')
).toBeVisible()
// Ctrl+O expands all
await comfyPage.page.keyboard.press('Control+o')
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/nodes|types/i)
// Ctrl+O folds all back — `nodes:` from the body should be hidden again.
await comfyPage.page.keyboard.press('Control+o')
})
})

90
build/plugins/agentLog.ts Normal file
View File

@@ -0,0 +1,90 @@
import { appendFileSync, existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import type { Plugin } from 'vite'
/**
* Dev-only Vite plugin: accept POSTs to /__agent-log and append each
* JSONL line to a per-session file under ./tmp/agent-logs/.
*
* Filename: ./tmp/agent-logs/<YYYY-MM-DD>-<sessionId>.jsonl
* - <sessionId> is the 8-char id assigned in the browser logger and
* attached to every entry. One file per page load makes individual
* conversations trivially diff-able and grep-able without sifting
* through a daily mixed log.
* - Entries without a sessionId fall back to '<date>-orphan.jsonl' so
* unattributed lines don't get silently dropped.
*
* GET /__agent-log → returns the directory + a 1-line summary of recent
* session files (debugging aid).
*
* No-op in production builds (apply: 'serve'). Same origin as the Vite
* dev server so the browser-side logger can POST with a simple fetch().
*/
export function agentLogPlugin(): Plugin {
const LOG_DIR = join(process.cwd(), 'tmp', 'agent-logs')
return {
name: 'agent-log',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/__agent-log', (req, res) => {
if (req.method === 'GET') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ dir: LOG_DIR, mode: 'per-session' }))
return
}
if (req.method !== 'POST') {
res.statusCode = 405
res.end()
return
}
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
try {
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR, { recursive: true })
}
const date = new Date().toISOString().slice(0, 10)
const body = Buffer.concat(chunks).toString('utf8')
// Group lines by sessionId so a single batch carrying multiple
// sessions (rare but possible) lands in the right files.
const buckets = new Map<string, string[]>()
for (const raw of body.split('\n')) {
const line = raw.trim()
if (!line) continue
let sessionId = 'orphan'
try {
const parsed = JSON.parse(line) as { sessionId?: string }
if (
parsed.sessionId &&
/^[A-Za-z0-9-]{1,64}$/.test(parsed.sessionId)
) {
sessionId = parsed.sessionId
}
} catch {
// Keep raw text in the orphan bucket; don't drop it.
}
const arr = buckets.get(sessionId) ?? []
arr.push(line)
buckets.set(sessionId, arr)
}
for (const [sessionId, lines] of buckets) {
const file = join(LOG_DIR, `${date}-${sessionId}.jsonl`)
appendFileSync(file, lines.join('\n') + '\n', 'utf8')
}
res.statusCode = 204
res.end()
} catch (err) {
res.statusCode = 500
res.end(err instanceof Error ? err.message : String(err))
}
})
})
}
}
}

View File

@@ -1 +1,2 @@
export { agentLogPlugin } from './agentLog'
export { comfyAPIPlugin } from './comfyAPIPlugin'

View File

@@ -0,0 +1,164 @@
# 9. Frontend-only In-app Agent + Future Local-Agent Bridge
Date: 2026-04-26
## Status
Proposed
## Context
PR #11547 introduces an experimental in-browser agent (`ComfyAI`) that
lets users drive ComfyUI with natural language. It lives entirely in
`src/agent/` and runs in the SPA — prompt assembly, tool execution
(browser-side `run-js` + Comfy API calls), message storage, and IndexedDB
chat history all happen client-side. The LLM is reached directly from
the browser via the user's API key (OpenAI / OpenRouter / any
OpenAI-compatible gateway), with optional Comfy Cloud auth for the
small set of cloud nodes (Tripo / Tencent / Meshy / Gemini).
This frontend-only architecture is deliberate. It keeps the deployment
story trivial (no backend changes), keeps the user's API key out of
ComfyUI's backend, and works whether the backend is local or remote.
But it raises a coordination problem the moment users want their
**other agents** — Claude Code, a self-hosted CLI agent, a teammate's
agent on a different machine — to participate in the same conversation,
see the same workflow state, or take actions on the user's behalf.
The forces at play:
- **Privacy**: API keys must not leak to ComfyUI's backend or to other
observers. The frontend-only model makes this trivially true today.
- **Source of truth for graph state**: the canonical workflow lives in
LiteGraph's in-memory tree inside the SPA. Backend has the queue +
history but doesn't track unsaved edits. Any other agent that wants
current state must either read from the SPA or read a snapshot the
SPA publishes.
- **Tool affordance**: the agent's `run_shell` tool currently executes
in the browser page context (DOM, stores, fetch with same-origin
cookies). A local agent has none of that — it would need either a
separate REST surface or to drive the SPA remotely.
- **Identity**: the SPA can hold a Comfy Cloud token; a local agent is
a separate principal and should hold its own credentials.
- **Versioning**: the moment we expose a wire format, breaking changes
hurt. Whatever we ship first becomes the contract.
The question this ADR exists to answer: **how should a local agent
participate in the in-app agent's session, given the frontend-only
constraint we want to preserve?**
## Decision
**Short term (this PR and the next few): keep the agent strictly
frontend-only.** Do not add any backend session state, message
relaying, or local-agent bridge. The current architecture is small,
auditable, and removes whole categories of risk.
**Long term: when local-agent integration is taken on, prefer Option C
("opt-in publish bus with execution staying in the SPA") over the
alternatives.** The detailed shape:
1. Define a small JSON-RPC schema for "agent context" — current
workflow id + serialized graph, last N messages, last K tool
invocations, agent settings (model + base URL only, never key).
Versioned from the start.
2. SPA exposes a "Share session" toggle in agent settings. When on,
it publishes that snapshot to a configurable WS endpoint
(default: `ws://localhost:7437/agent`). The user explicitly opts
in per session.
3. Provide a tiny reference subscriber library that local agents use
to consume. They get **read-only access by default**; getting
write access (post a message back into the user's panel) requires
the SPA to authorize via a one-time pairing code shown to the
user.
4. **Tool execution stays in the SPA.** Local agents can _propose_
actions ("run this run-js"); the SPA executes and streams the
result back. The local agent is a peer that suggests, not an
actor that mutates.
**Alternatives considered and rejected (for now):**
- **Option A — ComfyUI backend as session broker.** Push messages to
the running ComfyUI server, local agents subscribe via WS or
polling. Rejected because ComfyUI is meant to be largely stateless,
adding session storage is scope creep, and it puts API keys / chat
content in front of the backend (privacy regression).
- **Option B — browser extension or local sidecar daemon.** A
companion daemon reads the SPA's IndexedDB via Chrome DevTools
Protocol, or the SPA opens a localhost WS to it. Rejected as the
default path because of the cross-platform packaging burden and
because it doesn't help when the local agent runs on a different
machine than the SPA.
**Comfy Cloud creds reuse (a related future work item):** when the
user is signed into Comfy Cloud (the `auth_token_comfy_org` flow we
already use for Tripo/Gemini), the agent could optionally route LLM
calls through a Comfy-managed inference endpoint instead of OpenAI
direct. This would gate naturally on the same auth as the cloud
nodes and simplifies onboarding for users who don't have their own
OpenAI/OpenRouter key. Out of scope here, but worth noting because
it interacts with the local-agent identity story above.
## Consequences
### Positive
- **No backend changes today.** PR #11547 lands without touching
ComfyUI core. Reviewers don't need to evaluate session-state
infrastructure they didn't ask for.
- **Privacy posture stays strong.** API keys + chat content stay in
the user's browser; ComfyUI backend continues to see only what it
always saw (queue prompts, file uploads).
- **Future local-agent path is clear** without committing to a
protocol prematurely. When we build it, the SPA stays the
source-of-truth + execution sandbox; the local agent is a peer that
suggests. Mirrors how editors coexist with Claude Code, GitHub
Copilot, etc.
- **Headroom for multi-subscriber.** Option C naturally supports
agent + observer + log-tap subscribers with the same protocol —
useful for future debugging tools.
- **Versioned wire format** means breaking changes are explicit.
### Negative
- **Local agents have no participation today.** Users who want their
Claude Code session to see what they're doing in ComfyUI need to
copy/paste workflow JSON manually.
- **When we do build the bridge, it's net-new infrastructure** — a
WS server, a pairing flow, a versioning policy, a reference
subscriber library. Not trivial.
- **Tool execution stays in the SPA** even after the bridge ships,
which means a local agent on a different machine can't `run-js`
against the user's session without the SPA being open. (We accept
this as a privacy + simplicity tradeoff.)
- **The "Share session" toggle is yet another decision the user has
to make**, with non-obvious risks. Mitigations: clear UX copy,
default off, pairing-code requirement for write access.
## Notes
- The frontend-only constraint also drove several smaller decisions
in the PR that are worth recording briefly:
- Reasoning guardrails (`PROMISSORY_PATTERN`, `vetScript`,
`verifyClaims`) live in the SPA in `src/agent/llm/session.ts`,
not in a separate service. They survive prompt drift because
they're code, not text.
- Chat history is persisted via `useIDBKeyval` to IndexedDB. This
is a per-browser-profile store; switching profiles or clearing
site data wipes history. Acceptable for the experimental phase;
if local-agent bridge ships, the snapshot the SPA publishes
becomes another effective "external" history mechanism.
- The default LLM is `gpt-5.4` via OpenAI's official API. The
settings panel exposes a base-URL field so users can target
OpenRouter (`https://openrouter.ai/api/v1`) or any OpenAI-compatible
gateway. This base-URL flexibility also makes Option C's "Comfy
Cloud as inference endpoint" trivially achievable later — it's just
another base-URL choice.
- Concrete near-term TODOs flagged by this PR's stress-testing,
_not_ covered by this ADR but related:
- Layer 3 of the reasoning guardrails (structured JSON answers
with provenance) needs SDK plumbing to surface tool-call IDs
alongside text. Currently deferred.
- Verifier registry and shell-idiom blocklist are open
registries; entries grow as new failure modes surface in real
use.

View File

@@ -8,16 +8,17 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| ADR | Title | Status | Date |
| ---------------------------------------------------------- | ------------------------------------------------------ | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-frontend-only-agent-and-local-agent-bridge.md) | Frontend-only In-app Agent + Future Local-Agent Bridge | Proposed | 2026-04-26 |
## Creating a New ADR

View File

@@ -0,0 +1,78 @@
# 案 B: バックエンドの `/features` に `comfy_api_base` を追加する
## 背景
ComfyUI バックエンドは `--comfy-api-base` CLI フラグで Comfy Cloud の API ベース URLprod / staging / カスタム)を選択する。
フロントエンドは `__USE_PROD_CONFIG__` ビルド時定数で同じ値を選ぶ。
両者が食い違うと、フロントエンドが発行した Firebase トークン(または API キー)が
バックエンド経由で別の環境に投げられ、認証や課金が落ちる。
現状の検出方法(案 A、`src/views/ConnectionPanelView.vue`)は
`/api/system_stats``system.argv`CLI 全引数)から `--comfy-api-base` を grep するもの。
動くが脆い:
- 引数の書式(`--flag VALUE` vs `--flag=VALUE`)に依存する
- バックエンド側の CLI シグネチャが変わると壊れる
- 「公開 API ではない情報」を検出ロジックに使っている
## 提案
ComfyUI 本体の `/features` エンドポイントに `comfy_api_base` を追加する。
`/features` はすでに「構造化された機能/設定の公開 API」という位置付けがあり、ここに含めるのが自然。
### バックエンドの実装スケッチ
```python
# tmp/ComfyUI/comfy_api/feature_flags.py:65 付近
def get_server_features() -> dict[str, Any]:
from comfy.cli_args import args
return {
...,
"comfy_api_base": args.comfy_api_base,
}
```
### フロントエンドの変更
```ts
// 例: src/platform/connectionPanel/ あたりに移設
const features = await fetch(`${base}/api/features`).then((r) => r.json())
const backendCloudBase =
features.comfy_api_base ?? parseBackendCloudBase(stats.system?.argv)
```
`features.comfy_api_base` を優先し、未定義の場合のみ `argv` フォールバックを使う。
## メリット
- 構造化された公開 API になり、CLI 変更の影響を受けない
- 拡張機能 / カスタムノードからも安定して参照できる
- 既存の `/features` パターン(ファースト クラスのバックエンド能力公開)に合致
- フロントエンドの検出コードが自明になる
## デメリット
- `Comfy-Org/ComfyUI` 本体への PR とリリースが必要
- リリース前は案 A をフォールバックとして残す必要がある
- `comfy_api_base` を「公開してよい情報」と扱う合意が必要
(カスタム URL を使うユーザーには内部 URL が露出することになる)
## ロードマップ
1. **案 A をフロントエンドに実装(このコミット)**
- `ConnectionPanelView.vue``/system_stats``argv` を解析
- 不一致を検出した場合は黄色の警告を表示
2. `Comfy-Org/ComfyUI``/features` 拡張 PR を提出
- `comfy_api/feature_flags.py:65``comfy_api_base` を追加
3. 本体リリース後、フロントエンドを `features.comfy_api_base` 優先に切替
- `argv` フォールバックは互換性のために残す
4. 数バージョン後、`argv` フォールバックを削除
## 関連ファイル
- ComfyUI 本体: `comfy/cli_args.py:229``--comfy-api-base` 引数定義(デフォルト `https://api.comfy.org`
- ComfyUI 本体: `comfy_api/feature_flags.py:65``get_server_features()` の現状
- ComfyUI 本体: `server.py:646-685``/system_stats` ハンドラ(`argv` を返している)
- フロントエンド: `src/config/comfyApi.ts:21-31``getComfyApiBaseUrl()`(フロント側のビルド時定数)
- フロントエンド: `src/views/ConnectionPanelView.vue` — 案 A 実装場所
- フロントエンド: `src/platform/remoteConfig/refreshRemoteConfig.ts``/features` 既存利用

View File

@@ -27,7 +27,12 @@ const commonGlobals = {
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
__IS_NIGHTLY__: 'readonly',
__CI_BRANCH__: 'readonly',
__CI_PR_NUMBER__: 'readonly',
__CI_PR_AUTHOR__: 'readonly',
__CI_RUN_ID__: 'readonly',
__CI_JOB_ID__: 'readonly'
} as const
const settings = {

5
global.d.ts vendored
View File

@@ -2,6 +2,11 @@ declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __CI_BRANCH__: string
declare const __CI_PR_NUMBER__: string
declare const __CI_PR_AUTHOR__: string
declare const __CI_RUN_ID__: string
declare const __CI_JOB_ID__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean

View File

@@ -56,6 +56,7 @@
"clean": "nx reset"
},
"dependencies": {
"@ai-sdk/openai": "catalog:",
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
@@ -89,6 +90,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
"ai": "catalog:",
"algoliasearch": "catalog:",
"axios": "catalog:",
"chart.js": "^4.5.0",
@@ -101,6 +103,7 @@
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "catalog:",
"idb-keyval": "catalog:",
"jsonata": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
@@ -110,6 +113,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"shell-quote": "catalog:",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
@@ -147,6 +151,7 @@
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/shell-quote": "catalog:",
"@types/three": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"@vitest/coverage-v8": "catalog:",

249
pnpm-lock.yaml generated
View File

@@ -6,12 +6,15 @@ settings:
catalogs:
default:
'@ai-sdk/openai':
specifier: ^3.0.53
version: 3.0.53
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@astrojs/check':
specifier: ^0.9.8
version: 0.9.8
version: 0.9.9
'@astrojs/sitemap':
specifier: ^3.7.1
version: 3.7.1
@@ -159,6 +162,9 @@ catalogs:
'@types/semver':
specifier: ^7.7.0
version: 7.7.0
'@types/shell-quote':
specifier: ^1.7.5
version: 1.7.5
'@types/three':
specifier: ^0.169.0
version: 0.169.0
@@ -183,6 +189,9 @@ catalogs:
'@webgpu/types':
specifier: ^0.1.66
version: 0.1.66
ai:
specifier: ^6.0.168
version: 6.0.168
algoliasearch:
specifier: ^5.21.0
version: 5.21.0
@@ -251,13 +260,16 @@ catalogs:
version: 16.5.0
gsap:
specifier: ^3.14.2
version: 3.14.2
version: 3.15.0
happy-dom:
specifier: ^20.0.11
version: 20.0.11
husky:
specifier: ^9.1.7
version: 9.1.7
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
jiti:
specifier: 2.6.1
version: 2.6.1
@@ -272,7 +284,7 @@ catalogs:
version: 6.3.1
lenis:
specifier: ^1.3.21
version: 1.3.21
version: 1.3.23
lint-staged:
specifier: ^16.2.7
version: 16.4.0
@@ -284,7 +296,7 @@ catalogs:
version: 2.71.0
monocart-coverage-reports:
specifier: ^2.12.9
version: 2.12.9
version: 2.12.11
nx:
specifier: 22.6.1
version: 22.6.1
@@ -324,6 +336,9 @@ catalogs:
rollup-plugin-visualizer:
specifier: ^6.0.4
version: 6.0.4
shell-quote:
specifier: ^1.8.3
version: 1.8.3
storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -416,6 +431,9 @@ importers:
.:
dependencies:
'@ai-sdk/openai':
specifier: 'catalog:'
version: 3.0.53(zod@3.25.76)
'@alloc/quick-lru':
specifier: 'catalog:'
version: 5.2.0
@@ -502,7 +520,7 @@ importers:
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
'@vueuse/integrations':
specifier: 'catalog:'
version: 14.2.0(axios@1.13.5)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.3))
version: 14.2.0(axios@1.13.5)(fuse.js@7.0.0)(idb-keyval@6.2.2)(vue@3.5.13(typescript@5.9.3))
'@vueuse/router':
specifier: ^14.2.0
version: 14.2.1(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
@@ -515,6 +533,9 @@ importers:
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
ai:
specifier: 'catalog:'
version: 6.0.168(zod@3.25.76)
algoliasearch:
specifier: 'catalog:'
version: 5.21.0
@@ -551,6 +572,9 @@ importers:
glob:
specifier: 'catalog:'
version: 13.0.6
idb-keyval:
specifier: 'catalog:'
version: 6.2.2
jsonata:
specifier: 'catalog:'
version: 2.1.0
@@ -578,6 +602,9 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.4
shell-quote:
specifier: 'catalog:'
version: 1.8.3
three:
specifier: ^0.170.0
version: 0.170.0
@@ -684,6 +711,9 @@ importers:
'@types/semver':
specifier: 'catalog:'
version: 7.7.0
'@types/shell-quote':
specifier: 'catalog:'
version: 1.7.5
'@types/three':
specifier: 'catalog:'
version: 0.169.0
@@ -770,7 +800,7 @@ importers:
version: 2.71.0
monocart-coverage-reports:
specifier: 'catalog:'
version: 2.12.9
version: 2.12.11
nx:
specifier: 'catalog:'
version: 22.6.1
@@ -945,10 +975,10 @@ importers:
version: 1.0.0-beta.4(typescript@5.9.3)
gsap:
specifier: 'catalog:'
version: 3.14.2
version: 3.15.0
lenis:
specifier: 'catalog:'
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
version: 1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -958,10 +988,10 @@ importers:
devDependencies:
'@astrojs/check':
specifier: 'catalog:'
version: 0.9.8(prettier@3.7.4)(typescript@5.9.3)
version: 0.9.9(prettier@3.7.4)(typescript@5.9.3)
'@astrojs/vue':
specifier: 'catalog:'
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
'@playwright/test':
specifier: 'catalog:'
version: 1.58.1
@@ -970,7 +1000,7 @@ importers:
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
astro:
specifier: 'catalog:'
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
version: 5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
tailwindcss:
specifier: 'catalog:'
version: 4.2.0
@@ -1055,6 +1085,28 @@ packages:
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
'@ai-sdk/gateway@3.0.104':
resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.53':
resolution: {integrity: sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.23':
resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@alcalzone/ansi-tokenize@0.2.5':
resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==}
engines: {node: '>=18'}
@@ -1130,11 +1182,11 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@astrojs/check@0.9.8':
resolution: {integrity: sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw==}
'@astrojs/check@0.9.9':
resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==}
hasBin: true
peerDependencies:
typescript: ^5.0.0
typescript: ^5.0.0 || ^6.0.0
'@astrojs/compiler@2.13.1':
resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==}
@@ -1142,8 +1194,8 @@ packages:
'@astrojs/internal-helpers@0.7.6':
resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==}
'@astrojs/language-server@2.16.6':
resolution: {integrity: sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug==}
'@astrojs/language-server@2.16.7':
resolution: {integrity: sha512-b64bWT74Vq/ORcSqW7TdIjjpB6hcl+Ei/lMANIUaAGlLPiYNtPTRI/j2tzvugT+LoVwfJtE2Ukq/t2OGCyEtfQ==}
hasBin: true
peerDependencies:
prettier: ^3.0.0
@@ -4490,6 +4542,9 @@ packages:
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/shell-quote@1.7.5':
resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==}
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
@@ -4750,6 +4805,10 @@ packages:
vue-router:
optional: true
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vitejs/plugin-vue-jsx@4.2.0':
resolution: {integrity: sha512-DSTrmrdLp+0LDNF77fqrKfx7X0ErRbOcUAgJL/HbSesqQwoUvUQ4uYQqaex+rovqgGcoPqVk+AwUh3v9CuiYIw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -5129,6 +5188,12 @@ packages:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ai@6.0.168:
resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -5687,8 +5752,8 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
console-grid@2.2.3:
resolution: {integrity: sha512-+mecFacaFxGl+1G31IsCx41taUXuW2FxX+4xIE0TIPhgML+Jb9JFcBWGhhWerd1/vhScubdmHqTwOhB0KCUUAg==}
console-grid@2.2.4:
resolution: {integrity: sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==}
constantinople@4.0.1:
resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==}
@@ -6382,6 +6447,10 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
eventsource-parser@3.0.8:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -6539,6 +6608,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
foreground-child@4.0.3:
resolution: {integrity: sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==}
engines: {node: '>=16'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
@@ -6716,8 +6789,8 @@ packages:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
gsap@3.14.2:
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
h3@1.15.10:
resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==}
@@ -6855,6 +6928,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -7245,6 +7321,9 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -7330,8 +7409,8 @@ packages:
resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==}
engines: {node: '>=18'}
lenis@1.3.21:
resolution: {integrity: sha512-RXWTYm7KQE4Kv8ezxL6wvK0Oiv7aRr6FDo+eNaaniTeu7pLdHokqMIJ5CXO4x5ezvd+9ONdpSFkprLpXsVWmEw==}
lenis@1.3.23:
resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
@@ -7602,8 +7681,8 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
lz-utils@2.1.0:
resolution: {integrity: sha512-CMkfimAypidTtWjNDxY8a1bc1mJdyEh04V2FfEQ5Zh8Nx4v7k850EYa+dOWGn9hKG5xOyHP5MkuduAZCTHRvJw==}
lz-utils@2.1.1:
resolution: {integrity: sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==}
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
@@ -7881,12 +7960,12 @@ packages:
resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
engines: {node: '>=18.0.0'}
monocart-coverage-reports@2.12.9:
resolution: {integrity: sha512-vtFqbC3Egl4nVa1FSIrQvMPO6HZtb9lo+3IW7/crdvrLNW2IH8lUsxaK0TsKNmMO2mhFWwqQywLV2CZelqPgwA==}
monocart-coverage-reports@2.12.11:
resolution: {integrity: sha512-yo4/FdUdFIWoc9OjhBZCNXM95tYHS4e8nov9Q3AGbpvteT/W5aQSc4B+Q0nhmedZFvjvm3BUH/Xu9GT2n/0wkw==}
hasBin: true
monocart-locator@1.0.2:
resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==}
monocart-locator@1.0.3:
resolution: {integrity: sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
@@ -8255,6 +8334,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
@@ -8839,6 +8922,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
@@ -9170,6 +9257,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
@@ -10239,6 +10330,30 @@ snapshots:
'@adobe/css-tools@4.4.4': {}
'@ai-sdk/gateway@3.0.104(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
'@vercel/oidc': 3.2.0
zod: 3.25.76
'@ai-sdk/openai@3.0.53(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils@4.0.23(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.8
zod: 3.25.76
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
'@alcalzone/ansi-tokenize@0.2.5':
dependencies:
ansi-styles: 6.2.3
@@ -10348,9 +10463,9 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@astrojs/check@0.9.8(prettier@3.7.4)(typescript@5.9.3)':
'@astrojs/check@0.9.9(prettier@3.7.4)(typescript@5.9.3)':
dependencies:
'@astrojs/language-server': 2.16.6(prettier@3.7.4)(typescript@5.9.3)
'@astrojs/language-server': 2.16.7(prettier@3.7.4)(typescript@5.9.3)
chokidar: 4.0.3
kleur: 4.1.5
typescript: 5.9.3
@@ -10363,7 +10478,7 @@ snapshots:
'@astrojs/internal-helpers@0.7.6': {}
'@astrojs/language-server@2.16.6(prettier@3.7.4)(typescript@5.9.3)':
'@astrojs/language-server@2.16.7(prettier@3.7.4)(typescript@5.9.3)':
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/yaml2ts': 0.2.3
@@ -10373,7 +10488,7 @@ snapshots:
'@volar/language-server': 2.4.28
'@volar/language-service': 2.4.28
muggle-string: 0.4.1
tinyglobby: 0.2.15
tinyglobby: 0.2.16
volar-service-css: 0.0.70(@volar/language-service@2.4.28)
volar-service-emmet: 0.0.70(@volar/language-service@2.4.28)
volar-service-html: 0.0.70(@volar/language-service@2.4.28)
@@ -10436,12 +10551,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
dependencies:
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/compiler-sfc': 3.5.28
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
astro: 5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
vue: 3.5.13(typescript@5.9.3)
@@ -13807,6 +13922,8 @@ snapshots:
'@types/semver@7.7.0': {}
'@types/shell-quote@1.7.5': {}
'@types/stats.js@0.17.3': {}
'@types/three@0.169.0':
@@ -14044,6 +14161,8 @@ snapshots:
vue: 3.5.13(typescript@5.9.3)
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
'@vercel/oidc@3.2.0': {}
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.0
@@ -14478,7 +14597,7 @@ snapshots:
'@vueuse/shared': 14.2.0(vue@3.5.13(typescript@5.9.3))
vue: 3.5.13(typescript@5.9.3)
'@vueuse/integrations@14.2.0(axios@1.13.5)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.3))':
'@vueuse/integrations@14.2.0(axios@1.13.5)(fuse.js@7.0.0)(idb-keyval@6.2.2)(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.2.0(vue@3.5.13(typescript@5.9.3))
'@vueuse/shared': 14.2.0(vue@3.5.13(typescript@5.9.3))
@@ -14486,6 +14605,7 @@ snapshots:
optionalDependencies:
axios: 1.13.5
fuse.js: 7.0.0
idb-keyval: 6.2.2
'@vueuse/metadata@12.8.2': {}
@@ -14574,6 +14694,14 @@ snapshots:
dependencies:
humanize-ms: 1.2.1
ai@6.0.168(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.104(zod@3.25.76)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ajv-draft-04@1.0.0(ajv@8.13.0):
optionalDependencies:
ajv: 8.13.0
@@ -14770,7 +14898,7 @@ snapshots:
astral-regex@2.0.0: {}
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
astro@5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
@@ -14825,7 +14953,7 @@ snapshots:
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.4
unstorage: 1.17.4(idb-keyval@6.2.2)
vfile: 6.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
@@ -15285,7 +15413,7 @@ snapshots:
consola@3.4.2: {}
console-grid@2.2.3: {}
console-grid@2.2.4: {}
constantinople@4.0.1:
dependencies:
@@ -16123,6 +16251,8 @@ snapshots:
eventemitter3@5.0.4: {}
eventsource-parser@3.0.8: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -16225,6 +16355,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
fflate@0.4.8: {}
fflate@0.8.2: {}
@@ -16327,6 +16461,10 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
foreground-child@4.0.3:
dependencies:
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data@4.0.5:
@@ -16526,7 +16664,7 @@ snapshots:
section-matter: 1.0.0
strip-bom-string: 1.0.0
gsap@3.14.2: {}
gsap@3.15.0: {}
h3@1.15.10:
dependencies:
@@ -16732,6 +16870,8 @@ snapshots:
husky@9.1.7: {}
idb-keyval@6.2.2: {}
idb@7.1.1: {}
ieee754@1.2.1: {}
@@ -17121,6 +17261,8 @@ snapshots:
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json-stable-stringify@1.3.0:
@@ -17217,7 +17359,7 @@ snapshots:
dependencies:
package-json: 10.0.1
lenis@1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3)):
lenis@1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3)):
optionalDependencies:
react: 19.2.4
vue: 3.5.13(typescript@5.9.3)
@@ -17426,7 +17568,7 @@ snapshots:
lz-string@1.5.0: {}
lz-utils@2.1.0: {}
lz-utils@2.1.1: {}
magic-string-ast@1.0.3:
dependencies:
@@ -17900,22 +18042,22 @@ snapshots:
modern-tar@0.7.3: {}
monocart-coverage-reports@2.12.9:
monocart-coverage-reports@2.12.11:
dependencies:
acorn: 8.16.0
acorn-loose: 8.5.2
acorn-walk: 8.3.5
commander: 14.0.3
console-grid: 2.2.3
console-grid: 2.2.4
eight-colors: 1.3.3
foreground-child: 3.3.1
foreground-child: 4.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
lz-utils: 2.1.0
monocart-locator: 1.0.2
lz-utils: 2.1.1
monocart-locator: 1.0.3
monocart-locator@1.0.2: {}
monocart-locator@1.0.3: {}
mrmime@2.0.1: {}
@@ -18406,6 +18548,8 @@ snapshots:
picomatch@4.0.3: {}
picomatch@4.0.4: {}
pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
@@ -19230,6 +19374,8 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.23.0
@@ -19635,6 +19781,11 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@2.1.0: {}
tinyrainbow@2.0.0: {}
@@ -20001,7 +20152,7 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
unstorage@1.17.4:
unstorage@1.17.4(idb-keyval@6.2.2):
dependencies:
anymatch: 3.1.3
chokidar: 5.0.0
@@ -20011,6 +20162,8 @@ snapshots:
node-fetch-native: 1.6.7
ofetch: 1.5.1
ufo: 1.6.3
optionalDependencies:
idb-keyval: 6.2.2
update-browserslist-db@1.2.2(browserslist@4.28.1):
dependencies:

View File

@@ -3,6 +3,7 @@ packages:
- packages/**
catalog:
'@ai-sdk/openai': ^3.0.53
'@alloc/quick-lru': ^5.2.0
'@astrojs/check': ^0.9.8
'@astrojs/sitemap': ^3.7.1
@@ -54,6 +55,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/shell-quote': ^1.7.5
'@types/three': ^0.169.0
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -62,6 +64,7 @@ catalog:
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
ai: ^6.0.168
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
@@ -87,6 +90,7 @@ catalog:
gsap: ^3.14.2
happy-dom: ^20.0.11
husky: ^9.1.7
idb-keyval: ^6.2.2
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
@@ -109,6 +113,7 @@ catalog:
primevue: ^4.2.5
reka-ui: ^2.5.0
rollup-plugin-visualizer: ^6.0.4
shell-quote: ^1.8.3
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0

View File

@@ -0,0 +1,209 @@
#!/bin/bash
set -e
# Deploy frontend preview to Cloudflare Pages and comment on PR
# Usage: ./pr-preview-deploy-and-comment.sh <pr_number> <status>
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Validate status parameter
STATUS="${2:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- COMFYUI_PREVIEW_DEPLOY -->"
# Resolve wrangler invocation: prefer a locally-available binary, otherwise
# run via pnpm dlx to honour the repo's package-manager policy.
if command -v wrangler > /dev/null 2>&1; then
WRANGLER="wrangler"
else
WRANGLER="pnpm dlx wrangler@^4.0.0"
fi
# Deploy frontend preview, WARN: ensure inputs are sanitized before calling this function
deploy_preview() {
dir="$1"
branch="$2"
[ ! -d "$dir" ] && echo "failed" && return
project="comfy-ui"
echo "Deploying frontend preview to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch is already sanitized, use it directly
if output=$($WRANGLER pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Prefer the branch alias URL over the deployment hash URL so the
# link in the PR comment stays stable across redeploys.
branch_url="https://${branch}.${project}.pages.dev"
if echo "$output" | grep -qF "$branch_url"; then
result="$branch_url"
else
# Fall back to first pages.dev URL in wrangler output
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-$branch_url}"
fi
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
comment="$COMMENT_MARKER
## 🌐 Frontend Preview: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
post_comment "$comment"
elif [ "$STATUS" = "completed" ]; then
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
# Falls back to pr-$PR_NUMBER if BRANCH_NAME is unset
if [ -n "$BRANCH_NAME" ]; then
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
else
cloudflare_branch="pr-$PR_NUMBER"
fi
echo "Looking for frontend build in: $(pwd)/dist"
# Deploy preview if build exists
deployment_url="Not deployed"
if [ -d "dist" ]; then
echo "Found frontend build, deploying..."
url=$(deploy_preview "dist" "$cloudflare_branch")
if [ "$url" != "failed" ] && [ -n "$url" ]; then
deployment_url="[🌐 Open Preview]($url)"
else
deployment_url="Deployment failed"
fi
else
echo "Frontend build not found at dist"
fi
# Get workflow conclusion from environment or default to success
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
WORKFLOW_URL="${WORKFLOW_URL:-}"
# Generate compact header based on conclusion
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
status_icon="✅"
status_text="Built"
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
status_icon="⏭️"
status_text="Skipped"
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
status_icon="🚫"
status_text="Cancelled"
else
status_icon="❌"
status_text="Failed"
fi
# Build compact header with optional preview link
header="## 🌐 Frontend Preview: $status_icon $status_text"
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
header="$header$deployment_url"
fi
# Build details section
details="<details>
<summary>Details</summary>
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
**Links**
- [📊 View Workflow Run]($WORKFLOW_URL)"
if [ "$deployment_url" != "Not deployed" ]; then
if [ "$deployment_url" = "Deployment failed" ]; then
details="$details
- ❌ Preview deployment failed"
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⚠️ Build failed — $deployment_url"
fi
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⏭️ Preview deployment skipped (build did not succeed)"
fi
details="$details
</details>"
comment="$COMMENT_MARKER
$header
$details"
post_comment "$comment"
fi

View File

@@ -1,6 +1,7 @@
<template>
<router-view />
<GlobalDialog />
<AgentRoot />
<BlockUI full-screen :blocked="isLoading" />
</template>
@@ -9,6 +10,7 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, watch } from 'vue'
import AgentRoot from '@/agent/ui/AgentRoot.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'

View File

@@ -0,0 +1,265 @@
import { useLocalStorage } from '@vueuse/core'
import type { ModelMessage } from 'ai'
import { shallowRef } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import type { ToolInvocation } from '../llm/session'
import { streamSession } from '../llm/session'
import { log } from '../services/logger'
import { registerBrowserCommands } from '../shell/commands/browser'
import { registerCodesearchCommands } from '../shell/commands/codesearch'
import { registerComfyCommands } from '../shell/commands/comfy'
import { registerComfyNamespace } from '../shell/commands/comfyNs'
import { registerCoreutils } from '../shell/commands/coreutils'
import { registerExecutionCommands } from '../shell/commands/execution'
import { registerGraphCommands } from '../shell/commands/graph'
import { registerImageCommands } from '../shell/commands/images'
import { registerInstallCommands } from '../shell/commands/install'
import { registerLayoutCommands } from '../shell/commands/layout'
import { registerNodeOpsCommands } from '../shell/commands/nodeOps'
import { registerRegistrySearchCommands } from '../shell/commands/registrySearch'
import { registerSeeCommands } from '../shell/commands/see'
import { registerStateCommands } from '../shell/commands/state'
import { registerSweepCommands } from '../shell/commands/sweep'
import { registerTemplateCommands } from '../shell/commands/templates'
import { registerValidateCommands } from '../shell/commands/validate'
import { registerWorkflowCommands } from '../shell/commands/workflow'
import { CommandRegistryImpl, runScript } from '../shell/runtime'
import type { ExecContext } from '../shell/runtime'
import { collect, emptyIter, stringIter } from '../shell/types'
import type { Command } from '../shell/types'
import { MemoryVFS } from '../shell/vfs/memory'
import { MountedVFS } from '../shell/vfs/mount'
import { UserdataVFS } from '../shell/vfs/userdata'
import type { IngestedAsset } from '../stores/agentStore'
import { useAgentStore } from '../stores/agentStore'
// User's preferred smartest-available model. Override via settings.
const DEFAULT_MODEL = 'gpt-5.5'
const DEFAULT_REASONING_EFFORT = 'high'
const DEFAULT_SYSTEM_APPEND = ''
// Empty by default — the OpenAI SDK falls back to https://api.openai.com.
// User can point this at OpenRouter / a local LLM proxy / a self-hosted
// gateway by overriding via the settings panel.
const DEFAULT_BASE_URL = ''
function buildExecContext(signal: AbortSignal): ExecContext {
const registry = new CommandRegistryImpl()
registerCoreutils(registry)
registerComfyCommands(registry)
registerComfyNamespace(registry)
registerStateCommands(registry)
registerBrowserCommands(registry)
registerCodesearchCommands(registry)
registerExecutionCommands(registry)
registerGraphCommands(registry)
registerImageCommands(registry)
registerInstallCommands(registry)
registerLayoutCommands(registry)
registerNodeOpsCommands(registry)
registerRegistrySearchCommands(registry)
registerSeeCommands(registry)
registerSweepCommands(registry)
registerTemplateCommands(registry)
registerValidateCommands(registry)
registerWorkflowCommands(registry)
// Fallback: any Comfy.* (or other registered) command id can be invoked
// directly as if it were a shell command. Case-insensitive.
registry.addResolver((name) => {
const store = useCommandStore()
const target =
store.getCommand(name) ??
store.commands.find((c) => c.id.toLowerCase() === name.toLowerCase())
if (!target) return undefined
const handler: Command = async () => {
try {
await store.execute(target.id)
return { stdout: stringIter(`ok: ${target.id}\n`), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
return handler
})
const vfs = new MountedVFS({
'/tmp': new MemoryVFS(),
'/workflows': new UserdataVFS('workflows')
})
return {
registry,
vfs,
env: new Map(),
cwd: '/',
signal
}
}
function envApiKey(): string {
const key = import.meta.env.VITE_OPENAI_API_KEY
return typeof key === 'string' ? key : ''
}
export function useAgentSession() {
const store = useAgentStore()
const apiKey = useLocalStorage('Comfy.Agent.OpenAIKey', envApiKey())
const model = useLocalStorage('Comfy.Agent.Model', DEFAULT_MODEL)
const baseURL = useLocalStorage('Comfy.Agent.BaseURL', DEFAULT_BASE_URL)
const reasoningEffort = useLocalStorage(
'Comfy.Agent.ReasoningEffort',
DEFAULT_REASONING_EFFORT
)
const systemPromptAppend = useLocalStorage(
'Comfy.Agent.SystemPromptAppend',
DEFAULT_SYSTEM_APPEND
)
const abortController = shallowRef<AbortController | null>(null)
function buildHistory(): ModelMessage[] {
return store.messages
.filter((m) => m.role !== 'system')
.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.text
}))
}
async function send(text: string, assets: IngestedAsset[]): Promise<void> {
// Abort any in-flight stream from a prior turn so the old callbacks
// stop writing into the wrong placeholder and the new turn starts
// from a clean state.
if (abortController.value) {
abortController.value.abort()
abortController.value = null
store.isStreaming = false
}
const userContent =
assets.length > 0
? `${text}\n\nAttached files:\n${assets.map((a) => `- ${a.path}`).join('\n')}`
: text
store.addMessage({ role: 'user', text, assets })
if (!apiKey.value) {
store.addMessage({
role: 'assistant',
text:
'No API key configured yet. Click the ⚙ settings gear at the top of this panel and paste an OpenAI or OpenRouter API key. ' +
"This agent runs entirely in your browser — your key is stored in localStorage and only sent to the API endpoint you configure (default: OpenAI). It's never seen by the ComfyUI frontend or backend."
})
return
}
const placeholder = store.addMessage({ role: 'assistant', text: '' })
const ac = new AbortController()
abortController.value = ac
store.isStreaming = true
const history = buildHistory()
history[history.length - 1] = { role: 'user', content: userContent }
try {
let streamed = ''
const toolCalls: ToolInvocation[] = []
await streamSession(
{
apiKey: apiKey.value,
model: model.value,
baseURL: baseURL.value || undefined,
reasoningEffort: reasoningEffort.value,
systemPromptAppend: systemPromptAppend.value,
messages: history,
execContext: buildExecContext(ac.signal),
signal: ac.signal
},
(delta) => {
if (ac.signal.aborted) return
streamed += delta
placeholder.text = streamed
},
(inv) => {
if (ac.signal.aborted) return
toolCalls.push(inv)
const summary = `$ ${inv.script}\n${inv.stdout}${inv.stderr ? `\n[stderr] ${inv.stderr}` : ''}`
store.addMessage({
role: 'system',
text: summary,
tool: {
script: inv.script,
stdout: inv.stdout,
stderr: inv.stderr,
exitCode: inv.exitCode
}
})
}
)
// Fallback: model ran tools but didn't speak — surface a minimal
// confirmation so the user isn't staring at tool traces alone.
if (!ac.signal.aborted && !streamed.trim() && toolCalls.length > 0) {
const last = toolCalls[toolCalls.length - 1]
placeholder.text =
last.exitCode === 0
? `(${toolCalls.length} tool call${toolCalls.length > 1 ? 's' : ''} completed)`
: `(tool exited ${last.exitCode})`
}
// Log the FINAL assistant text (agentStore.addMessage only logs the
// empty placeholder at creation time; we need a follow-up entry so
// the server log captures what the user actually saw).
if (!ac.signal.aborted && placeholder.text) {
log({ kind: 'assistant', text: placeholder.text })
}
} catch (err) {
if (!ac.signal.aborted) {
placeholder.text =
'Error: ' + (err instanceof Error ? err.message : String(err))
}
} finally {
// Only clear shared flags if we are still the active stream.
if (abortController.value === ac) {
store.isStreaming = false
abortController.value = null
}
}
}
function stop(): void {
abortController.value?.abort()
}
let cachedCtx: ExecContext | null = null
function buildExecContextOnce(): ExecContext {
if (!cachedCtx) {
cachedCtx = buildExecContext(new AbortController().signal)
}
return cachedCtx
}
async function execShell(
script: string
): Promise<{ stdout: string; stderr?: string; exitCode: number }> {
const ctx = buildExecContextOnce()
const ac = new AbortController()
const res = await runScript(script, { ...ctx, signal: ac.signal })
const stdout = await collect(res.stdout)
return { stdout, stderr: res.stderr, exitCode: res.exitCode }
}
return {
apiKey,
baseURL,
model,
reasoningEffort,
systemPromptAppend,
send,
stop,
execShell,
buildExecContextOnce
}
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest'
import { useAssetIngest } from './useAssetIngest'
function mockFile(name: string, type = 'image/png', size = 10): File {
return new File([new Uint8Array(size)], name, { type })
}
describe('useAssetIngest', () => {
it('uses uploader result path when upload succeeds', async () => {
const uploader = vi.fn().mockResolvedValue('/input/sub/foo.png')
const { ingestFile } = useAssetIngest({ uploader })
const result = await ingestFile(mockFile('foo.png'))
expect(result.remote).toBe(true)
expect(result.asset.path).toBe('/input/sub/foo.png')
expect(result.asset.mime).toBe('image/png')
})
it('falls back to /tmp/pasted when uploader returns null', async () => {
const uploader = vi.fn().mockResolvedValue(null)
const { ingestFile } = useAssetIngest({ uploader })
const result = await ingestFile(mockFile('x.png'))
expect(result.remote).toBe(false)
expect(result.asset.path).toMatch(/^\/tmp\/pasted\//)
})
it('sanitizes filenames', async () => {
const uploader = vi.fn().mockResolvedValue(null)
const { ingestFile } = useAssetIngest({ uploader })
const result = await ingestFile(mockFile('weird name !@#.png'))
expect(result.asset.path).not.toMatch(/[!@#]/)
})
it('creates preview URL for images only', async () => {
const uploader = vi.fn().mockResolvedValue(null)
const { ingestFile } = useAssetIngest({ uploader })
const img = await ingestFile(mockFile('a.png', 'image/png'))
const txt = await ingestFile(mockFile('a.txt', 'text/plain'))
expect(img.asset.previewUrl).toBeDefined()
expect(txt.asset.previewUrl).toBeUndefined()
})
it('ingests multiple files from DataTransfer', async () => {
const uploader = vi.fn().mockResolvedValue('/input/x')
const { ingestFromClipboard } = useAssetIngest({ uploader })
const dt = {
items: [
{ kind: 'file', getAsFile: () => mockFile('a.png') },
{ kind: 'file', getAsFile: () => mockFile('b.png') },
{ kind: 'string', getAsFile: () => null }
],
files: []
} as unknown as DataTransfer
const results = await ingestFromClipboard(dt)
expect(results).toHaveLength(2)
})
it('returns empty list when DataTransfer is null', async () => {
const { ingestFromClipboard } = useAssetIngest({})
expect(await ingestFromClipboard(null)).toEqual([])
})
})

View File

@@ -0,0 +1,92 @@
import { api } from '@/scripts/api'
import type { IngestedAsset } from '../stores/agentStore'
interface IngestResult {
asset: IngestedAsset
remote: boolean
}
function safeName(raw: string): string {
return raw.replace(/[^\w.-]+/g, '_').slice(0, 120) || `pasted_${Date.now()}`
}
function detectExt(mime: string): string {
if (mime === 'image/png') return '.png'
if (mime === 'image/jpeg') return '.jpg'
if (mime === 'image/webp') return '.webp'
if (mime === 'image/gif') return '.gif'
if (mime === 'text/plain') return '.txt'
return ''
}
async function uploadToInput(file: File): Promise<string | null> {
const body = new FormData()
body.append('image', file, file.name)
body.append('type', 'input')
body.append('overwrite', 'false')
try {
const resp = await api.fetchApi('/upload/image', { method: 'POST', body })
if (!resp.ok) return null
const json = (await resp.json()) as { name?: string; subfolder?: string }
if (!json.name) return null
const prefix = json.subfolder ? `${json.subfolder}/` : ''
return `/input/${prefix}${json.name}`
} catch {
return null
}
}
interface AssetIngestOptions {
uploader?: (file: File) => Promise<string | null>
}
export function useAssetIngest(options: AssetIngestOptions = {}) {
const uploader = options.uploader ?? uploadToInput
async function ingestFile(file: File): Promise<IngestResult> {
const remotePath = await uploader(file)
const fallbackName =
file.name && file.name.length > 0
? safeName(file.name)
: safeName('pasted') + detectExt(file.type)
const path = remotePath ?? `/tmp/pasted/${fallbackName}`
const previewUrl = file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined
return {
asset: {
id: crypto.randomUUID(),
name: fallbackName,
path,
mime: file.type || 'application/octet-stream',
size: file.size,
previewUrl
},
remote: remotePath !== null
}
}
async function ingestFromClipboard(
data: DataTransfer | null
): Promise<IngestResult[]> {
if (!data) return []
const results: IngestResult[] = []
for (const item of Array.from(data.items)) {
if (item.kind !== 'file') continue
const file = item.getAsFile()
if (file) results.push(await ingestFile(file))
}
if (results.length === 0 && data.files && data.files.length > 0) {
for (const file of Array.from(data.files)) {
results.push(await ingestFile(file))
}
}
return results
}
return {
ingestFile,
ingestFromClipboard
}
}

View File

@@ -0,0 +1,70 @@
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Drop an uploaded image into the active graph as a LoadImage node.
*
* Given an uploaded filename (the part after `/input/` returned by
* /upload/image), add a LoadImage node at a reasonable position and
* set its widget to the filename. Capture an undo snapshot so Ctrl/Cmd+Z
* reverts the insertion.
*
* Returns the id of the newly created node, or null if the graph was
* not available or the node type is not registered.
*/
export function dropImageAsLoadImageNode(filename: string): number | null {
const canvas = useCanvasStore().canvas
const graph = canvas?.graph as
| { _nodes: { pos: [number, number]; size: [number, number] }[] }
| undefined
if (!canvas || !graph) return null
// Position: to the right of the rightmost existing node, same y as the
// topmost. Feels natural when adding a reference image alongside a
// workflow.
let right = 100
let top = 100
const nodes = graph._nodes ?? []
if (nodes.length > 0) {
right = Math.max(
...nodes.map((n) => (n.pos?.[0] ?? 0) + (n.size?.[0] ?? 200))
)
right += 40
top = Math.min(...nodes.map((n) => n.pos?.[1] ?? 0))
}
// The global LiteGraph instance is installed by the app startup; access
// it via window to avoid tangling imports.
const LG = (
window as unknown as { LiteGraph?: { createNode: (t: string) => unknown } }
).LiteGraph
if (!LG) return null
const node = LG.createNode('LoadImage') as {
id: number
pos: [number, number]
widgets?: {
name?: string
value?: unknown
callback?: (v: unknown) => void
}[]
} | null
if (!node) return null
node.pos = [right, top]
// Set the 'image' widget to the uploaded filename
const widget = node.widgets?.find((w) => w.name === 'image')
if (widget) {
widget.value = filename
widget.callback?.(filename)
}
;(graph as unknown as { add: (n: unknown) => void }).add(node)
canvas.setDirty(true, true)
try {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch {
/* no active workflow */
}
return node.id
}

View File

@@ -0,0 +1,236 @@
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useAgentStore } from '../stores/agentStore'
import { useAgentSession } from './useAgentSession'
const DAEMON_WS = 'ws://127.0.0.1:7437/spa'
const PROTOCOL_VERSION = 1
const SESSION_ID = crypto.randomUUID()
type SpaEventPayload =
| { kind: 'delta'; role: 'assistant'; text: string }
| { kind: 'message'; role: 'user' | 'assistant' | 'system'; text: string }
| {
kind: 'tool'
script: string
stdout: string
stderr?: string
exitCode: number
}
| { kind: 'state'; isStreaming: boolean }
| { kind: 'clear' }
type SpaToDaemon =
| { v: number; type: 'hello'; sessionId: string; title?: string }
| {
v: number
type: 'evalResult'
sessionId: string
opId: string
stdout: string
stderr?: string
exitCode: number
}
| { v: number; type: 'pair-request'; sessionId: string; code: string }
| { v: number; type: 'pong'; sessionId: string }
| { v: number; type: 'event'; payload: SpaEventPayload }
type DaemonToSpa =
| { v: number; type: 'send'; text: string }
| { v: number; type: 'eval'; opId: string; script: string }
| { v: number; type: 'abort' }
| { v: number; type: 'paired'; code: string }
| { v: number; type: 'ping' }
// Singleton state — shared across all callers of useLocalBridge()
const connected = ref(false)
const activePairCode = ref<string | null>(null)
let ws: WebSocket | null = null
let refCount = 0
let sendFn: ((text: string) => void) | null = null
let evalFn:
| ((
opId: string,
script: string
) => Promise<{ stdout: string; stderr?: string; exitCode: number }>)
| null = null
let stopFn: (() => void) | null = null
// Tracks how many messages from the store have been emitted to the daemon.
// Reset to 0 when messages are cleared or a new WS connection opens.
let emittedMsgCount = 0
function sendMsg(msg: SpaToDaemon) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
}
function emitEvent(payload: SpaEventPayload) {
sendMsg({ v: PROTOCOL_VERSION, type: 'event', payload })
}
function connect(
onSend: typeof sendFn,
onEval: typeof evalFn,
onStop: typeof stopFn
) {
sendFn = onSend
evalFn = onEval
stopFn = onStop
if (ws && ws.readyState !== WebSocket.CLOSED) return
ws = new WebSocket(DAEMON_WS)
ws.addEventListener('open', () => {
connected.value = true
emittedMsgCount = 0
sendMsg({
v: PROTOCOL_VERSION,
type: 'hello',
sessionId: SESSION_ID,
title: 'ComfyUI'
})
})
ws.addEventListener('message', async (ev) => {
let msg: DaemonToSpa
try {
msg = JSON.parse(ev.data as string) as DaemonToSpa
} catch {
return
}
if (msg.v !== PROTOCOL_VERSION) return
switch (msg.type) {
case 'ping':
sendMsg({ v: PROTOCOL_VERSION, type: 'pong', sessionId: SESSION_ID })
break
case 'send':
sendFn?.(msg.text)
break
case 'eval': {
const result = (await evalFn?.(msg.opId, msg.script)) ?? {
stdout: '',
exitCode: 0
}
sendMsg({
v: PROTOCOL_VERSION,
type: 'evalResult',
sessionId: SESSION_ID,
opId: msg.opId,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode
})
break
}
case 'abort':
stopFn?.()
break
case 'paired':
if (activePairCode.value === msg.code) activePairCode.value = null
break
}
})
ws.addEventListener('close', () => {
connected.value = false
ws = null
// Reconnect after 3s if still mounted
if (refCount > 0)
setTimeout(() => {
if (refCount > 0) connect(sendFn, evalFn, stopFn)
}, 3000)
})
ws.addEventListener('error', () => {
connected.value = false
})
}
function disconnect() {
refCount--
if (refCount <= 0) {
ws?.close()
ws = null
refCount = 0
connected.value = false
}
}
/** Mount in the root component (AgentRoot) to manage the WS lifecycle. */
export function useLocalBridge() {
const { send, stop, execShell } = useAgentSession()
const agentStore = useAgentStore()
onMounted(() => {
refCount++
connect(
(text) => void send(text, []),
(_opId, script) => execShell(script),
() => stop()
)
})
onUnmounted(disconnect)
// Forward new messages to any subscribed tail/attach clients.
// We track `emittedMsgCount` so reconnects don't re-emit history.
watch(
() => agentStore.messages.length,
(newLen) => {
if (newLen < emittedMsgCount) {
emitEvent({ kind: 'clear' })
emittedMsgCount = 0
return
}
for (let i = emittedMsgCount; i < newLen; i++) {
const msg = agentStore.messages[i]
if (msg.tool) {
emitEvent({ kind: 'tool', ...msg.tool })
} else if (msg.role === 'assistant' && agentStore.isStreaming) {
// Streaming placeholder — wait until done to emit
} else {
emitEvent({
kind: 'message',
role: msg.role as 'user' | 'assistant' | 'system',
text: msg.text
})
}
emittedMsgCount = i + 1
}
}
)
// Emit streaming state transitions and flush the final assistant message.
watch(
() => agentStore.isStreaming,
(isStreaming) => {
emitEvent({ kind: 'state', isStreaming })
if (!isStreaming) {
const msgs = agentStore.messages
const last = msgs[msgs.length - 1]
if (last?.role === 'assistant' && last.text) {
emitEvent({ kind: 'message', role: 'assistant', text: last.text })
emittedMsgCount = msgs.length
}
}
}
)
}
function requestPair(): void {
const code = Math.random().toString(36).slice(2, 8).toUpperCase()
activePairCode.value = code
sendMsg({
v: PROTOCOL_VERSION,
type: 'pair-request',
sessionId: SESSION_ID,
code
})
}
/** Read bridge state from any component — no lifecycle side-effects. */
export function useBridgeStatus() {
return { connected, activePairCode, requestPair }
}

1091
src/agent/llm/session.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
/**
* Agent log — streamed in real time to the Vite dev server's
* /__agent-log endpoint, which appends each JSONL line to
* ./tmp/agent-logs/<YYYY-MM-DD>.jsonl on the repo host.
*
* In production (no dev-plugin endpoint) the POST silently 404s and the
* logger becomes a no-op. To persist in production a later backend
* endpoint (or userdata fallback) would be needed.
*/
interface LogEntry {
t: number
kind: 'user' | 'assistant' | 'system' | 'tool' | 'error' | 'session'
sessionId?: string
text?: string
script?: string
stdout?: string
stderr?: string
exitCode?: number
}
const SESSION_ID =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as Crypto).randomUUID().slice(0, 8)
: Math.random().toString(36).slice(2, 10)
const ENDPOINT = '/__agent-log'
let queue: LogEntry[] = []
let flushTimer: ReturnType<typeof setTimeout> | null = null
let flushing = false
let disabled = false
async function doFlush(): Promise<void> {
if (flushing || queue.length === 0 || disabled) return
flushing = true
const batch = queue.splice(0)
const body = batch.map((e) => JSON.stringify(e)).join('\n') + '\n'
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-ndjson' },
body,
keepalive: true
})
if (res.status === 404) {
// Endpoint doesn't exist (production build). Stop trying.
disabled = true
}
} catch {
// Keep the entries for a retry
queue = batch.concat(queue)
} finally {
flushing = false
if (queue.length > 0) schedule(400)
}
}
function schedule(delay = 250): void {
if (flushTimer || disabled) return
flushTimer = setTimeout(() => {
flushTimer = null
void doFlush()
}, delay)
}
export function log(partial: Omit<LogEntry, 't' | 'sessionId'>): void {
if (disabled) return
queue.push({ t: Date.now(), sessionId: SESSION_ID, ...partial })
schedule()
}
// Best-effort flush on tab close (uses navigator.sendBeacon-style fetch keepalive)
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
void doFlush()
})
// Mark session start
log({ kind: 'session', text: 'session started' })
}

View File

@@ -0,0 +1,210 @@
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { Command, CommandRegistry } from '../types'
import { stringIter } from '../types'
/**
* run-js: Execute arbitrary JavaScript with the ComfyUI app + Pinia stores
* injected as locals, so snippets like
* useCanvasStore().canvas.graph._nodes
* work without any import dance.
*
* Locals bound in the eval scope:
* app, api, document, window,
* useCanvasStore, useCommandStore, useWorkflowStore,
* useMissingModelStore, useExecutionErrorStore, useSettingStore
*/
const INJECT = [
'app',
'api',
'document',
'window',
'useCanvasStore',
'useCommandStore',
'useWorkflowStore',
'useMissingModelStore',
'useExecutionErrorStore',
'useSettingStore',
'useColorPaletteStore'
] as const
/**
* Strip outermost matching quotes (single/double/backtick). The pre-parse
* shortcut for run-js passes the arg verbatim so the LLM often wraps its
* snippet in quotes as it would in a shell — but here those quotes become
* part of the JS source and collapse the whole thing to a string literal
* that evaluates to undefined. Strip them so it works either way.
*/
function stripOuterQuotes(s: string): string {
const trimmed = s.trim()
if (trimmed.length < 2) return trimmed
const first = trimmed[0]
const last = trimmed[trimmed.length - 1]
if ((first === '"' || first === "'" || first === '`') && first === last) {
return trimmed.slice(1, -1)
}
return trimmed
}
const runJs: Command = async (ctx) => {
const code = stripOuterQuotes(ctx.argv.slice(1).join(' '))
if (!code.trim()) {
return {
stdout: stringIter(''),
exitCode: 2,
stderr: 'usage: run-js <js expression or statement>'
}
}
try {
// Intentional: run-js is a DevTools-equivalent eval entry point.
const FnCtor = Function
const fn = new FnCtor(...INJECT, `return (async () => { ${code} })()`) as (
...args: unknown[]
) => Promise<unknown>
const result: unknown = await fn(
app,
api,
document,
window,
useCanvasStore,
useCommandStore,
useWorkflowStore,
useMissingModelStore,
useExecutionErrorStore,
useSettingStore,
useColorPaletteStore
)
const out =
result === undefined ? '' : JSON.stringify(result, null, 2) + '\n'
return { stdout: stringIter(out), exitCode: 0 }
} catch (err) {
return {
stdout: stringIter(''),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* describe <js-expression>
*
* Introspect the shape of any value in the run-js scope (stores, app,
* canvas, nodes …). Returns type, constructor, own-property summary,
* and prototype methods — without dumping huge payloads.
*
* Examples:
* describe useCanvasStore().canvas.graph
* describe app.canvas
* describe useCanvasStore().canvas.graph._nodes[0]
*/
const describeCmd: Command = async (ctx) => {
const expr = stripOuterQuotes(ctx.argv.slice(1).join(' '))
if (!expr) {
return {
stdout: stringIter(''),
exitCode: 2,
stderr: 'usage: describe <expression>'
}
}
try {
const FnCtor = Function
const fn = new FnCtor(
...INJECT,
`return (async () => { return (${expr}) })()`
) as (...args: unknown[]) => Promise<unknown>
const value: unknown = await fn(
app,
api,
document,
window,
useCanvasStore,
useCommandStore,
useWorkflowStore,
useMissingModelStore,
useExecutionErrorStore,
useSettingStore,
useColorPaletteStore
)
return { stdout: stringIter(formatShape(value) + '\n'), exitCode: 0 }
} catch (err) {
return {
stdout: stringIter(''),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
function formatShape(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
const t = typeof value
if (t !== 'object' && t !== 'function') {
return `${t}: ${JSON.stringify(value)}`
}
const ctor =
(value as object).constructor?.name ??
(t === 'function' ? 'Function' : 'object')
const lines: string[] = [`${ctor} (${t})`]
if (Array.isArray(value)) {
lines.push(` length: ${value.length}`)
if (value.length > 0) {
lines.push(` [0]: ${summariseValue(value[0])}`)
if (value.length > 1)
lines.push(` [-1]: ${summariseValue(value[value.length - 1])}`)
}
return lines.join('\n')
}
const obj = value as Record<string, unknown>
const keys = Object.keys(obj).sort()
if (keys.length > 0) {
lines.push(` own properties (${keys.length}):`)
for (const k of keys.slice(0, 40)) {
lines.push(` ${k}: ${summariseValue(obj[k])}`)
}
if (keys.length > 40) lines.push(`${keys.length - 40} more`)
}
// Prototype methods (one level up, shallow)
const proto = Object.getPrototypeOf(value)
if (proto && proto !== Object.prototype && proto !== Function.prototype) {
const protoKeys = Object.getOwnPropertyNames(proto)
.filter((k) => k !== 'constructor')
.sort()
if (protoKeys.length > 0) {
lines.push(` prototype methods (${protoKeys.length}):`)
lines.push(' ' + protoKeys.slice(0, 30).join(', '))
if (protoKeys.length > 30)
lines.push(`${protoKeys.length - 30} more`)
}
}
return lines.join('\n')
}
function summariseValue(v: unknown): string {
if (v === null) return 'null'
if (v === undefined) return 'undefined'
const t = typeof v
if (t === 'function') return 'function'
if (t === 'string') return `string(${(v as string).length})`
if (t === 'number' || t === 'boolean') return `${t} ${String(v)}`
if (Array.isArray(v)) return `Array(${v.length})`
if (t === 'object') {
const ctor = (v as object).constructor?.name ?? 'object'
return ctor
}
return t
}
export function registerBrowserCommands(registry: CommandRegistry): void {
registry.register('run-js', runJs)
registry.register('describe', describeCmd)
}

View File

@@ -0,0 +1,171 @@
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
const API_BASE = 'https://comfy-codesearch.vercel.app'
const DEFAULT_COUNT = 20
interface LineMatch {
preview?: string
lineNumber?: number
}
interface FileMatch {
__typename?: string
repository?: { name?: string }
file?: { path?: string }
lineMatches?: LineMatch[]
}
interface RepoMatch {
__typename?: string
name?: string
}
interface SearchResponse {
data?: {
search?: {
stats?: { approximateResultCount?: string }
results?: {
matchCount?: number
elapsedMilliseconds?: number
results?: (FileMatch | RepoMatch)[]
}
}
}
}
async function csFetch(
endpoint: 'code' | 'repo',
query: string
): Promise<SearchResponse> {
const url = `${API_BASE}/api/search/${endpoint}?query=${encodeURIComponent(query)}`
const res = await fetch(url)
if (!res.ok) {
throw new Error(
`comfy-codesearch ${endpoint}: API error ${res.status} ${res.statusText}`
)
}
return (await res.json()) as SearchResponse
}
function formatCodeResults(json: SearchResponse, query: string): string {
const r = json.data?.search?.results
const stats = json.data?.search?.stats
const hits = (r?.results ?? []) as FileMatch[]
if (hits.length === 0) {
return `no matches for "${query}" across the public ComfyUI codebase.\n`
}
const repos = new Set<string>()
for (const h of hits) if (h.repository?.name) repos.add(h.repository.name)
const header =
`${r?.matchCount ?? hits.length} match(es) in ${repos.size} repo(s)` +
(stats?.approximateResultCount
? ` (~${stats.approximateResultCount} total)`
: '') +
(r?.elapsedMilliseconds !== undefined
? `, took ${r.elapsedMilliseconds}ms`
: '') +
':\n'
const lines: string[] = []
for (const h of hits) {
const repo = h.repository?.name ?? '?'
const path = h.file?.path ?? '?'
const lms = h.lineMatches ?? []
if (lms.length === 0) {
lines.push(` ${repo} ${path}`)
continue
}
for (const lm of lms) {
const ln = lm.lineNumber ?? '?'
const preview = (lm.preview ?? '').replace(/\s+$/, '')
lines.push(` ${repo} ${path}:${ln}\n ${preview}`)
}
}
return header + lines.join('\n') + '\n'
}
function formatRepoResults(json: SearchResponse, query: string): string {
const r = json.data?.search?.results
const hits = (r?.results ?? []) as RepoMatch[]
if (hits.length === 0) {
return `no repos match "${query}" in the public ComfyUI codebase index.\n`
}
const lines = hits.map((h) => ' ' + (h.name ?? '?'))
return `${hits.length} repo(s) match "${query}":\n` + lines.join('\n') + '\n'
}
/**
* comfy-codesearch <query> [--repo] [--count N]
*
* Search source code (or repo names) across the WHOLE public ComfyUI
* community via cs.comfy.org. Use this to find node-class definitions,
* extension APIs, or example code in repos that aren't yet published to
* the registry — `node-search-registry` only sees published packs, but
* many custom nodes live as plain GitHub repos.
*
* Query syntax is Sourcegraph-flavored:
* - plain text fuzzy substring across all indexed repos
* - `repo:Comfy-Org/ComfyUI foo` scope to a specific repo
* - `count:50 foo` cap result count (otherwise --count is used)
* - `class\\s+Wacom` regex
*
* Examples:
* comfy-codesearch "NODE_CLASS_MAPPINGS.*[Ww]acom"
* comfy-codesearch --repo wacom
* comfy-codesearch "repo:Comfy-Org/ComfyUI last_node_id" --count 5
*/
const comfyCodesearch: Command = async (ctx) => {
const args = ctx.argv.slice(1)
let mode: 'code' | 'repo' = 'code'
let count = DEFAULT_COUNT
const queryParts: string[] = []
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--repo' || a === '-r') {
mode = 'repo'
} else if (a === '--count' || a === '-c') {
const next = args[i + 1]
const n = Number(next)
if (!Number.isFinite(n) || n <= 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `comfy-codesearch: --count needs a positive number, got "${next ?? ''}"`
}
}
count = n
i++
} else {
queryParts.push(a)
}
}
const query = queryParts.join(' ').trim()
if (!query) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'usage: comfy-codesearch <query> [--repo] [--count N]\n' +
' (searches the whole public ComfyUI community via cs.comfy.org)'
}
}
let effectiveQuery = query
if (mode === 'code' && !/\bcount:\d+/.test(query)) {
effectiveQuery = `count:${count} ${query}`
}
try {
const json = await csFetch(mode, effectiveQuery)
const text =
mode === 'code'
? formatCodeResults(json, query)
: formatRepoResults(json, query)
return { stdout: stringIter(text), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
export function registerCodesearchCommands(registry: CommandRegistry): void {
registry.register('comfy-codesearch', comfyCodesearch)
}

View File

@@ -0,0 +1,60 @@
import { useCommandStore } from '@/stores/commandStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
const cmd: Command = async (ctx) => {
const id = ctx.argv[1]
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: cmd <command-id> [args...]'
}
}
const store = useCommandStore()
const target = store.getCommand(id)
if (!target) {
return {
stdout: emptyIter(),
exitCode: 127,
stderr: `cmd: unknown command id: ${id}`
}
}
try {
await store.execute(id)
return { stdout: stringIter(`ok: ${id}\n`), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
const cmdList: Command = async (ctx) => {
const store = useCommandStore()
const patterns = ctx.argv.slice(1).filter(Boolean)
const ids = store.commands
.map((c) => c.id)
.filter((id) => {
if (patterns.length === 0) return true
const lc = id.toLowerCase()
return patterns.some((p) => {
try {
return new RegExp(p, 'i').test(id)
} catch {
return lc.includes(p.toLowerCase())
}
})
})
.sort()
const out = ids.length === 0 ? '(no matches)\n' : ids.join('\n') + '\n'
return { stdout: stringIter(out), exitCode: 0 }
}
export function registerComfyCommands(registry: CommandRegistry): void {
registry.register('cmd', cmd)
registry.register('cmd-list', cmdList)
}

View File

@@ -0,0 +1,222 @@
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import type { CmdContext, Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* Namespace dispatcher for Comfy.* UI commands.
*
* Usage:
* comfy list top-level namespaces (Canvas, Workflow…)
* comfy --help same
* comfy canvas list commands under Canvas
* comfy canvas --help same
* comfy canvas fitview execute Comfy.Canvas.FitView
* comfy canvas fitview --help show description / shortcut / version
*
* Names match case-insensitive. Dot form (Comfy.Canvas.FitView) also works —
* that's routed via the registry resolver, this command just gives the
* nicer space-separated git-like ergonomics and --help at every level.
*/
interface CommandEntry {
id: string
label: string
tooltip?: string
versionAdded?: string
}
function allCommands(): CommandEntry[] {
return useCommandStore().commands.map((c) => ({
id: c.id,
label: c.label ?? c.id,
tooltip: c.tooltip,
versionAdded: c.versionAdded
}))
}
function filterByPath(
cmds: CommandEntry[],
pathParts: string[]
): {
exact: CommandEntry | null
childNamesAtNextLevel: string[]
descendants: CommandEntry[]
} {
const lower = pathParts.map((p) => p.toLowerCase())
const descendants = cmds.filter((c) => {
const parts = c.id.split('.').map((p) => p.toLowerCase())
if (parts.length <= lower.length) return false
for (let i = 0; i < lower.length; i++) {
if (parts[i + 1] !== lower[i]) return false
}
return true
})
const exact =
cmds.find(
(c) => c.id.toLowerCase() === ['comfy', ...lower].join('.').toLowerCase()
) ?? null
const nextLevelSet = new Set<string>()
for (const c of descendants) {
const parts = c.id.split('.')
const nextPart = parts[lower.length + 1]
if (nextPart) nextLevelSet.add(nextPart)
}
return {
exact,
childNamesAtNextLevel: [...nextLevelSet].sort(),
descendants
}
}
function formatHelp(
path: string[],
entries: CommandEntry[],
children: string[]
): string {
const header = path.length === 0 ? 'comfy' : 'comfy ' + path.join(' ')
const lines: string[] = []
lines.push(`\x1b[1m${header}\x1b[0m — ComfyUI command namespace`)
lines.push('')
if (children.length > 0) {
lines.push('namespaces / subcommands:')
for (const name of children) {
// count how many commands are at or under this child
const prefix = 'Comfy.' + [...path, name].join('.').toLowerCase()
const count = entries.filter((c) =>
c.id.toLowerCase().startsWith(prefix)
).length
const suffix = count > 1 ? ` (${count} commands)` : ''
lines.push(` ${name.toLowerCase()}${suffix}`)
}
lines.push('')
}
lines.push(
'tip: append --help at any level for details, or run the leaf to execute.'
)
return lines.join('\n') + '\n'
}
function formatLeafHelp(entry: CommandEntry): string {
const lines: string[] = []
lines.push(`\x1b[1m${entry.id}\x1b[0m`)
if (entry.label && entry.label !== entry.id)
lines.push(` label: ${entry.label}`)
if (entry.tooltip) lines.push(` tooltip: ${entry.tooltip}`)
const kb = useKeybindingStore().getKeybindingByCommandId(entry.id)
if (kb?.combo) {
const keys = [
kb.combo.ctrl && 'Ctrl',
kb.combo.alt && 'Alt',
kb.combo.shift && 'Shift',
kb.combo.key
]
.filter(Boolean)
.join('+')
lines.push(` shortcut: ${keys}`)
}
if (entry.versionAdded) lines.push(` added: v${entry.versionAdded}`)
lines.push('')
lines.push(
'invocation: run without --help to execute, e.g. comfy ' +
entry.id
.replace(/^Comfy\./, '')
.split('.')
.join(' ')
.toLowerCase()
)
lines.push(' or: ' + entry.id)
return lines.join('\n') + '\n'
}
async function executeLeaf(
id: string,
args: string[] = []
): Promise<{
stdout: AsyncIterable<string>
exitCode: number
stderr?: string
}> {
const store = useCommandStore()
try {
await store.execute(id, { metadata: { args } })
const suffix = args.length > 0 ? ` (args: ${args.join(' ')})` : ''
return { stdout: stringIter(`ok: ${id}${suffix}\n`), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* Progressive leaf resolution: walk the path from longest to shortest,
* returning the first prefix that resolves to an exact registered command.
* The remaining trailing tokens become passthrough args (delivered via
* `metadata.args` to the command function).
*/
function resolveLongestLeaf(
cmds: CommandEntry[],
pathParts: string[]
): { leaf: CommandEntry; args: string[] } | null {
for (let n = pathParts.length; n >= 1; n--) {
const prefix = pathParts.slice(0, n)
const { exact } = filterByPath(cmds, prefix)
if (exact) return { leaf: exact, args: pathParts.slice(n) }
}
return null
}
const comfyCmd: Command = async (ctx: CmdContext) => {
const rawArgs = ctx.argv.slice(1)
const wantsHelp =
rawArgs[rawArgs.length - 1] === '--help' ||
rawArgs[rawArgs.length - 1] === '-h'
const pathParts = rawArgs.filter((a) => a !== '--help' && a !== '-h')
const cmds = allCommands()
const { exact, childNamesAtNextLevel, descendants } = filterByPath(
cmds,
pathParts
)
// Leaf command + --help → show that command's detail
if (exact && wantsHelp) {
return { stdout: stringIter(formatLeafHelp(exact)), exitCode: 0 }
}
// Leaf command (no --help) → execute
if (exact && childNamesAtNextLevel.length === 0) {
return executeLeaf(exact.id)
}
// If there's an exact match AND children, ambiguous: prefer execute when
// no more args, else treat as a namespace (shouldn't really happen in
// the current ComfyUI namespace but guard anyway).
if (exact && pathParts.length > 0 && !wantsHelp) {
return executeLeaf(exact.id)
}
// Not a leaf — try progressive resolution: maybe the first N tokens
// name a leaf and the rest are passthrough args (e.g.
// `comfy saveworkflowas bbb` → Comfy.SaveWorkflowAs with args=['bbb']).
if (pathParts.length > 0 && descendants.length === 0 && !exact) {
const resolved = resolveLongestLeaf(cmds, pathParts)
if (resolved) return executeLeaf(resolved.leaf.id, resolved.args)
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `comfy: no command or namespace '${pathParts.join(' ')}'`
}
}
return {
stdout: stringIter(formatHelp(pathParts, cmds, childNamesAtNextLevel)),
exitCode: 0
}
}
export function registerComfyNamespace(registry: CommandRegistry): void {
registry.register('comfy', comfyCmd)
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { CommandRegistryImpl } from '../runtime'
import type { CmdContext } from '../types'
import { collect, emptyIter, stringIter } from '../types'
import { MemoryVFS } from '../vfs/memory'
import { coreutils, registerCoreutils } from './coreutils'
function baseCtx(
argv: string[],
stdin: AsyncIterable<string> = emptyIter(),
vfs = new MemoryVFS()
): CmdContext {
return {
argv,
stdin,
env: new Map(),
cwd: '/',
vfs,
signal: new AbortController().signal
}
}
describe('coreutils', () => {
it('echo joins args with space', async () => {
const r = await coreutils.echo(baseCtx(['echo', 'hello', 'world']))
expect(await collect(r.stdout)).toBe('hello world\n')
})
it('echo -n omits newline', async () => {
const r = await coreutils.echo(baseCtx(['echo', '-n', 'hi']))
expect(await collect(r.stdout)).toBe('hi')
})
it('cat reads file', async () => {
const fs = new MemoryVFS()
await fs.write('/f', 'contents')
const r = await coreutils.cat(baseCtx(['cat', '/f'], emptyIter(), fs))
expect(await collect(r.stdout)).toBe('contents')
})
it('cat passes through stdin with no args', async () => {
const r = await coreutils.cat(baseCtx(['cat'], stringIter('passed\n')))
expect(await collect(r.stdout)).toBe('passed\n')
})
it('ls lists sorted entries', async () => {
const fs = new MemoryVFS()
await fs.write('/b', '')
await fs.write('/a', '')
await fs.write('/sub/x', '')
const r = await coreutils.ls(baseCtx(['ls', '/'], emptyIter(), fs))
expect(await collect(r.stdout)).toBe('a\nb\nsub/\n')
})
it('pwd emits cwd', async () => {
const r = await coreutils.pwd(baseCtx(['pwd']))
expect(await collect(r.stdout)).toBe('/\n')
})
it('wc counts lines, words, bytes', async () => {
const r = await coreutils.wc(baseCtx(['wc'], stringIter('a\nb\nc\n')))
expect(await collect(r.stdout)).toBe('3 3 6\n')
})
it('head -n 2 keeps first 2', async () => {
const r = await coreutils.head(
baseCtx(['head', '-n', '2'], stringIter('1\n2\n3\n4\n'))
)
expect(await collect(r.stdout)).toBe('1\n2\n')
})
it('tail -n 2 keeps last 2', async () => {
const r = await coreutils.tail(
baseCtx(['tail', '-n', '2'], stringIter('1\n2\n3\n4\n'))
)
expect(await collect(r.stdout)).toBe('3\n4\n')
})
it('grep filters', async () => {
const r = await coreutils.grep(
baseCtx(['grep', 'foo'], stringIter('foo\nbar\nfood\n'))
)
expect(await collect(r.stdout)).toBe('foo\nfood\n')
})
it('true exits 0, false exits 1', async () => {
expect((await coreutils.true(baseCtx(['true']))).exitCode).toBe(0)
expect((await coreutils.false(baseCtx(['false']))).exitCode).toBe(1)
})
it('seq N counts 1..N inclusive', async () => {
const r = await coreutils.seq(baseCtx(['seq', '3']))
expect(await collect(r.stdout)).toBe('1\n2\n3\n')
})
it('seq A B counts A..B inclusive', async () => {
const r = await coreutils.seq(baseCtx(['seq', '5', '8']))
expect(await collect(r.stdout)).toBe('5\n6\n7\n8\n')
})
it('seq A STEP B supports custom step', async () => {
const r = await coreutils.seq(baseCtx(['seq', '10', '5', '25']))
expect(await collect(r.stdout)).toBe('10\n15\n20\n25\n')
})
it('seq supports negative step', async () => {
const r = await coreutils.seq(baseCtx(['seq', '3', '-1', '1']))
expect(await collect(r.stdout)).toBe('3\n2\n1\n')
})
it('registerCoreutils registers all commands', () => {
const reg = new CommandRegistryImpl()
registerCoreutils(reg)
expect(reg.list()).toEqual(
[
'cat',
'echo',
'false',
'grep',
'head',
'ls',
'pwd',
'seq',
'tail',
'true',
'wc'
].sort()
)
})
})

View File

@@ -0,0 +1,184 @@
import type { CmdContext, CmdResult, Command, CommandRegistry } from '../types'
import { collect, emptyIter, lines, stringIter } from '../types'
function ok(stdout: AsyncIterable<string>, exitCode = 0): CmdResult {
return { stdout, exitCode }
}
function err(message: string, exitCode = 2): CmdResult {
return { stdout: emptyIter(), exitCode, stderr: message }
}
const echo: Command = async (ctx) => {
const args = ctx.argv.slice(1)
let newline = true
if (args[0] === '-n') {
newline = false
args.shift()
}
const text = args.join(' ') + (newline ? '\n' : '')
return ok(stringIter(text))
}
const cat: Command = async (ctx) => {
const paths = ctx.argv.slice(1)
if (paths.length === 0) return ok(ctx.stdin)
async function* gen(): AsyncIterable<string> {
for (const p of paths) {
yield await ctx.vfs.read(p)
}
}
return ok(gen())
}
const ls: Command = async (ctx) => {
const path = ctx.argv[1] ?? ctx.cwd
const entries = await ctx.vfs.list(path)
const out =
entries.map((e) => (e.type === 'dir' ? e.name + '/' : e.name)).join('\n') +
(entries.length > 0 ? '\n' : '')
return ok(stringIter(out))
}
const pwd: Command = async (ctx) => ok(stringIter(ctx.cwd + '\n'))
const wc: Command = async (ctx) => {
const data = await collect(ctx.stdin)
const bytes = data.length
const lineCount =
data === '' ? 0 : data.split('\n').length - (data.endsWith('\n') ? 1 : 0)
const words = data.split(/\s+/).filter((w) => w.length > 0).length
return ok(stringIter(`${lineCount} ${words} ${bytes}\n`))
}
function parseNFlag(
argv: string[],
defaultN: number
): { n: number; rest: string[] } {
const rest = argv.slice(1)
let n = defaultN
if (rest[0] === '-n') {
const parsed = Number(rest[1])
if (!Number.isFinite(parsed)) throw new Error('invalid -n value')
n = parsed
rest.splice(0, 2)
} else if (rest[0]?.startsWith('-n')) {
const parsed = Number(rest[0].slice(2))
if (!Number.isFinite(parsed)) throw new Error('invalid -n value')
n = parsed
rest.shift()
}
return { n, rest }
}
const head: Command = async (ctx) => {
let n: number
try {
;({ n } = parseNFlag(ctx.argv, 10))
} catch (e) {
return err('usage: head [-n N]')
}
async function* gen(): AsyncIterable<string> {
let i = 0
for await (const line of lines(ctx.stdin)) {
if (i >= n) break
yield line + '\n'
i++
}
}
return ok(gen())
}
const tail: Command = async (ctx) => {
let n: number
try {
;({ n } = parseNFlag(ctx.argv, 10))
} catch (e) {
return err('usage: tail [-n N]')
}
const buf: string[] = []
for await (const line of lines(ctx.stdin)) {
buf.push(line)
if (buf.length > n) buf.shift()
}
const out = buf.length > 0 ? buf.join('\n') + '\n' : ''
return ok(stringIter(out))
}
const grep: Command = async (ctx) => {
const pattern = ctx.argv[1]
if (!pattern) return err('usage: grep <pattern>')
const re = new RegExp(pattern)
// POSIX grep returns 1 when nothing matched. To honour that we have to
// drain stdin eagerly — exit codes are set on the Command return, but a
// generator can't change them after the fact. The agent relies on this
// for `grep ... && ...` / `grep ... || ...` flows; without the right
// exit code the LLM would conclude evidence existed when stdout was
// actually empty.
let matched = false
let out = ''
for await (const line of lines(ctx.stdin)) {
if (re.test(line)) {
out += line + '\n'
matched = true
}
}
return ok(stringIter(out), matched ? 0 : 1)
}
const trueCmd: Command = async () => ok(emptyIter(), 0)
const falseCmd: Command = async () => ok(emptyIter(), 1)
const seqCmd: Command = async (ctx) => {
const args = ctx.argv.slice(1).map(Number)
if (args.some((n) => !Number.isFinite(n))) {
return err('usage: seq [start] [step] end')
}
let start = 1,
step = 1,
end: number
if (args.length === 1) end = args[0]
else if (args.length === 2) {
start = args[0]
end = args[1]
} else if (args.length === 3) {
start = args[0]
step = args[1]
end = args[2]
} else {
return err('usage: seq [start] [step] end')
}
if (step === 0) return err('step must not be zero')
const out: string[] = []
if (step > 0) for (let i = start; i <= end; i += step) out.push(String(i))
else for (let i = start; i >= end; i += step) out.push(String(i))
return ok(stringIter(out.join('\n') + (out.length ? '\n' : '')))
}
export function registerCoreutils(registry: CommandRegistry): void {
registry.register('echo', echo)
registry.register('cat', cat)
registry.register('ls', ls)
registry.register('pwd', pwd)
registry.register('wc', wc)
registry.register('head', head)
registry.register('tail', tail)
registry.register('grep', grep)
registry.register('true', trueCmd)
registry.register('false', falseCmd)
registry.register('seq', seqCmd)
}
export const coreutils = {
echo,
cat,
ls,
pwd,
wc,
head,
tail,
grep,
true: trueCmd,
false: falseCmd,
seq: seqCmd
} satisfies Record<string, (ctx: CmdContext) => Promise<CmdResult>>

View File

@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/api', () => {
class FakeApi extends EventTarget {
getQueue = vi.fn()
getHistory = vi.fn()
getJobDetail = vi.fn()
}
return { api: new FakeApi() }
})
import { api } from '@/scripts/api'
import { CommandRegistryImpl } from '../runtime'
import type { CmdContext } from '../types'
import { collect, emptyIter } from '../types'
import { MemoryVFS } from '../vfs/memory'
import { registerExecutionCommands } from './execution'
const mocked = vi.mocked(api)
function ctx(argv: string[], signal?: AbortSignal): CmdContext {
return {
argv,
stdin: emptyIter(),
env: new Map(),
cwd: '/',
vfs: new MemoryVFS(),
signal: signal ?? new AbortController().signal
}
}
describe('execution commands', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('queue-status lists running and pending', async () => {
mocked.getQueue.mockResolvedValue({
Running: [
{ id: 'r1', status: 'in_progress', create_time: 1, priority: 0 }
],
Pending: [
{ id: 'p1', status: 'pending', create_time: 2, priority: 0 },
{ id: 'p2', status: 'pending', create_time: 3, priority: 0 }
]
})
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('queue-status')!(ctx(['queue-status']))
const out = await collect(res.stdout)
expect(out).toContain('running: 1')
expect(out).toContain('pending: 2')
expect(out).toContain('r1')
expect(out).toContain('p2')
})
it('history --last=2 returns 2 rows', async () => {
mocked.getHistory.mockResolvedValue([
{
id: 'a',
status: 'completed',
create_time: 1,
priority: 0
},
{
id: 'b',
status: 'completed',
create_time: 2,
priority: 0
}
])
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('history')!(ctx(['history', '--last=2']))
expect(mocked.getHistory).toHaveBeenCalledWith(2)
const out = await collect(res.stdout)
expect(out.split('\n').filter(Boolean)).toHaveLength(2)
})
it('wait-queue returns immediately when idle', async () => {
mocked.getQueue.mockResolvedValue({ Running: [], Pending: [] })
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('wait-queue')!(
ctx(['wait-queue', '--timeout=1', '--poll=1'])
)
expect(res.exitCode).toBe(0)
expect(await collect(res.stdout)).toMatch(/queue idle/)
})
it('wait-queue respects aborted signal', async () => {
mocked.getQueue.mockResolvedValue({
Running: [
{ id: 'r', status: 'in_progress', create_time: 1, priority: 0 }
],
Pending: []
})
const ac = new AbortController()
ac.abort()
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('wait-queue')!(
ctx(['wait-queue', '--timeout=1', '--poll=1'], ac.signal)
)
expect(res.exitCode).toBe(130)
})
it('latest-output returns no history when empty', async () => {
mocked.getHistory.mockResolvedValue([])
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('latest-output')!(ctx(['latest-output']))
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no history')
})
it('latest-output emits view URLs for image outputs', async () => {
mocked.getHistory.mockResolvedValue([
{
id: 'job-1',
status: 'completed',
create_time: 1,
priority: 0
}
])
mocked.getJobDetail.mockResolvedValue({
id: 'job-1',
status: 'completed',
create_time: 1,
priority: 0,
outputs: {
'9': {
images: [{ filename: 'out.png', subfolder: '', type: 'output' }]
}
}
})
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('latest-output')!(ctx(['latest-output']))
const out = await collect(res.stdout)
expect(out).toContain('job-1')
expect(out).toContain('/view?filename=out.png')
})
})

View File

@@ -0,0 +1,120 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface RawJob {
id: string
status: string
execution_error?: unknown
outputs_count?: number | null
workflow_id?: string | null
}
function jobState(j: RawJob): string {
if (j.execution_error) return 'error'
return j.status || 'unknown'
}
function fmtJob(j: RawJob): string {
return `${jobState(j)}\t${j.id}\t${j.workflow_id ?? ''}`
}
const queueStatus: Command = async () => {
const { Running, Pending } = await api.getQueue()
const lines: string[] = []
lines.push(`running: ${Running.length}`)
for (const j of Running) lines.push(' ' + fmtJob(j as unknown as RawJob))
lines.push(`pending: ${Pending.length}`)
for (const j of Pending) lines.push(' ' + fmtJob(j as unknown as RawJob))
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
const historyCmd: Command = async (ctx) => {
const arg = ctx.argv.find((a) => a.startsWith('--last='))
const last = arg ? Number(arg.slice(7)) : 10
const max = Number.isFinite(last) && last > 0 ? Math.min(last, 200) : 10
const items = await api.getHistory(max)
const lines = items.map((j) => fmtJob(j as unknown as RawJob))
return {
stdout: stringIter(lines.join('\n') + (lines.length ? '\n' : '')),
exitCode: 0
}
}
const waitQueue: Command = async (ctx) => {
const timeoutArg = ctx.argv.find((a) => a.startsWith('--timeout='))
const timeoutMs = timeoutArg ? Number(timeoutArg.slice(10)) * 1000 : 300_000
const pollArg = ctx.argv.find((a) => a.startsWith('--poll='))
const pollMs = pollArg ? Number(pollArg.slice(7)) * 1000 : 1000
const started = Date.now()
while (Date.now() - started < timeoutMs) {
if (ctx.signal.aborted) {
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
}
const { Running, Pending } = await api.getQueue()
if (Running.length === 0 && Pending.length === 0) {
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
return {
stdout: stringIter(`queue idle after ${elapsed}s\n`),
exitCode: 0
}
}
await new Promise((r) => setTimeout(r, pollMs))
}
return {
stdout: emptyIter(),
exitCode: 124,
stderr: `timed out after ${timeoutMs / 1000}s`
}
}
const latestOutput: Command = async () => {
const items = await api.getHistory(1)
if (items.length === 0) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no history' }
}
const job = items[0] as unknown as RawJob
const detail = await api.getJobDetail(job.id)
const outputs = detail?.outputs ?? {}
const previews: string[] = []
for (const [nodeId, out] of Object.entries(outputs)) {
const images = (
out as {
images?: { filename?: string; subfolder?: string; type?: string }[]
}
).images
if (!images) continue
for (const img of images) {
if (!img.filename) continue
const sub = img.subfolder
? `&subfolder=${encodeURIComponent(img.subfolder)}`
: ''
const type = img.type ? `&type=${encodeURIComponent(img.type)}` : ''
previews.push(
`node=${nodeId}\t/view?filename=${encodeURIComponent(img.filename)}${sub}${type}`
)
}
}
const state = jobState(job)
if (previews.length === 0) {
return {
stdout: stringIter(`job ${job.id}\t${state}\tno images\n`),
exitCode: 0
}
}
return {
stdout: stringIter(
[`job: ${job.id}`, `state: ${state}`, ...previews].join('\n') + '\n'
),
exitCode: 0
}
}
export function registerExecutionCommands(registry: CommandRegistry): void {
registry.register('queue-status', queueStatus)
registry.register('history', historyCmd)
registry.register('wait-queue', waitQueue)
registry.register('latest-output', latestOutput)
}

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const canvasRef = { value: null as unknown }
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get canvas() {
return canvasRef.value
},
set canvas(v: unknown) {
canvasRef.value = v
}
})
}))
import { CommandRegistryImpl } from '../runtime'
import type { CmdContext } from '../types'
import { collect, emptyIter } from '../types'
import { MemoryVFS } from '../vfs/memory'
import { registerGraphCommands } from './graph'
function ctx(argv: string[]): CmdContext {
return {
argv,
stdin: emptyIter(),
env: new Map(),
cwd: '/',
vfs: new MemoryVFS(),
signal: new AbortController().signal
}
}
function setGraph(nodes: unknown[]) {
canvasRef.value = { graph: { _nodes: nodes } }
}
describe('graph command', () => {
beforeEach(() => {
canvasRef.value = null
})
it('errors when no active graph', async () => {
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'summary']))
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no active graph')
})
it('summary lists type counts', async () => {
setGraph([
{ id: 1, comfyClass: 'KSampler' },
{ id: 2, comfyClass: 'KSampler' },
{ id: 3, comfyClass: 'CheckpointLoaderSimple' }
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'summary']))
const out = await collect(res.stdout)
expect(out).toContain('nodes: 3')
expect(out).toContain('2\tKSampler')
expect(out).toContain('1\tCheckpointLoaderSimple')
})
it('nodes with regex filters by type', async () => {
setGraph([
{ id: 1, comfyClass: 'KSampler', title: 'main' },
{ id: 2, comfyClass: 'CLIPTextEncode' }
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'nodes', 'KSampler']))
const out = await collect(res.stdout)
expect(out).toContain('1\tKSampler\tmain')
expect(out).not.toContain('CLIPTextEncode')
})
it('node <id> returns JSON summary', async () => {
setGraph([
{
id: 5,
comfyClass: 'KSampler',
pos: [10, 20],
widgets: [{ name: 'seed', value: 42, type: 'int' }],
inputs: [{ name: 'model', type: 'MODEL', link: null }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [1, 2] }]
}
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'node', '5']))
const out = await collect(res.stdout)
const parsed = JSON.parse(out)
expect(parsed.id).toBe(5)
expect(parsed.type).toBe('KSampler')
expect(parsed.widgets[0]).toEqual({ name: 'seed', value: 42, type: 'int' })
expect(parsed.outputs[0].linkCount).toBe(2)
})
it('node <id> errors on missing node', async () => {
setGraph([{ id: 1, comfyClass: 'X' }])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'node', '99']))
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no node 99')
})
it('set-widget mutates value and fires callback', async () => {
const cb = vi.fn()
setGraph([
{
id: 3,
comfyClass: 'KSampler',
widgets: [{ name: 'cfg', type: 'FLOAT', value: 8, callback: cb }]
}
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('set-widget')!(
ctx(['set-widget', '3', 'cfg', '6.5'])
)
expect(res.exitCode).toBe(0)
expect(cb).toHaveBeenCalledWith(6.5)
expect(await collect(res.stdout)).toContain('6.5')
})
it('set-widget errors on missing widget', async () => {
setGraph([{ id: 3, comfyClass: 'KSampler', widgets: [] }])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('set-widget')!(
ctx(['set-widget', '3', 'nope', '1'])
)
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no widget')
})
})

View File

@@ -0,0 +1,197 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface WidgetSummary {
name: string
value: unknown
type?: string
}
interface NodeSummary {
id: number | string
type: string
title?: string
pos?: [number, number]
mode?: number
widgets?: WidgetSummary[]
inputs?: { name: string; type: string; linkId?: number | null }[]
outputs?: { name: string; type: string; linkCount: number }[]
}
function getGraph() {
const canvas = useCanvasStore().canvas
return canvas?.graph ?? null
}
function summarizeNode(node: unknown): NodeSummary {
const n = node as {
id: number
type?: string
comfyClass?: string
title?: string
pos?: [number, number]
mode?: number
widgets?: { name?: string; value?: unknown; type?: string }[]
inputs?: { name?: string; type?: string; link?: number | null }[]
outputs?: {
name?: string
type?: string
links?: (number | null)[] | null
}[]
}
return {
id: n.id,
type: n.comfyClass ?? n.type ?? 'Unknown',
title: n.title,
pos: n.pos,
mode: n.mode,
widgets: n.widgets?.map((w) => ({
name: w.name ?? '',
value: w.value,
type: w.type
})),
inputs: n.inputs?.map((i) => ({
name: i.name ?? '',
type: i.type ?? '*',
linkId: i.link ?? null
})),
outputs: n.outputs?.map((o) => ({
name: o.name ?? '',
type: o.type ?? '*',
linkCount: Array.isArray(o.links)
? o.links.filter((l) => l != null).length
: 0
}))
}
}
const graphCmd: Command = async (ctx) => {
const graph = getGraph()
if (!graph) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const sub = ctx.argv[1] ?? 'summary'
if (sub === 'summary') {
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const types = new Map<string, number>()
for (const n of nodes) {
const s = summarizeNode(n)
types.set(s.type, (types.get(s.type) ?? 0) + 1)
}
const lines = [`nodes: ${nodes.length}`, 'types:']
for (const [t, c] of [...types.entries()].sort((a, b) => b[1] - a[1])) {
lines.push(` ${c}\t${t}`)
}
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
if (sub === 'nodes') {
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const filter = ctx.argv[2]
const summaries = nodes.map(summarizeNode)
const filtered = filter
? summaries.filter((s) => new RegExp(filter, 'i').test(s.type))
: summaries
const out = filtered
.map((s) => `${s.id}\t${s.type}\t${s.title ?? ''}`)
.join('\n')
return { stdout: stringIter(out + (out ? '\n' : '')), exitCode: 0 }
}
if (sub === 'node') {
const id = ctx.argv[2]
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: graph node <id>'
}
}
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const node = nodes.find((n) => String((n as { id: number }).id) === id)
if (!node) {
return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${id}` }
}
return {
stdout: stringIter(JSON.stringify(summarizeNode(node), null, 2) + '\n'),
exitCode: 0
}
}
if (sub === 'json') {
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const payload = { nodes: nodes.map(summarizeNode) }
return {
stdout: stringIter(JSON.stringify(payload, null, 2) + '\n'),
exitCode: 0
}
}
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `usage: graph <summary|nodes [regex]|node <id>|json>`
}
}
interface LiteWidget {
name?: string
type?: string
value?: unknown
callback?: (v: unknown) => void
}
interface LiteNode {
id: number | string
widgets?: LiteWidget[]
}
function coerce(type: string | undefined, raw: string): unknown {
if (type === 'number' || type === 'INT' || type === 'FLOAT') {
const n = Number(raw)
if (Number.isFinite(n)) return n
}
if (type === 'BOOLEAN' || type === 'toggle') {
if (raw === 'true') return true
if (raw === 'false') return false
}
return raw
}
const setWidget: Command = async (ctx) => {
const [, idArg, name, ...rest] = ctx.argv
if (!idArg || !name || rest.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: set-widget <nodeId> <widgetName> <value...>'
}
}
const graph = getGraph()
if (!graph) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const nodes = (graph as { _nodes: LiteNode[] })._nodes ?? []
const node = nodes.find((n) => String(n.id) === idArg)
if (!node) {
return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${idArg}` }
}
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `node ${idArg} has no widget "${name}"`
}
}
const value = coerce(widget.type, rest.join(' '))
widget.value = value
widget.callback?.(value)
return {
stdout: stringIter(`set ${idArg}.${name} = ${JSON.stringify(value)}\n`),
exitCode: 0
}
}
export function registerGraphCommands(registry: CommandRegistry): void {
registry.register('graph', graphCmd)
registry.register('set-widget', setWidget)
}

View File

@@ -0,0 +1,140 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* copy-to-input <output_filename> [as <input_filename>]
*
* Copies a file from the output/ directory into input/ so it can be used
* as a LoadImage source in the NEXT workflow. Unlocks multi-phase pipelines
* (e.g. T2I generates image → image-to-3D consumes it) in pure natural
* language via the agent.
*
* Fetches via /view?type=output, re-uploads via /upload/image.
*/
const copyToInput: Command = async (ctx) => {
const args = ctx.argv.slice(1)
if (args.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'usage: copy-to-input <output_filename> [as <input_filename>]\n' +
' copies output/<src> → input/<dst> (defaults dst = src)'
}
}
const src = args[0]
let dst = src
const asIdx = args.indexOf('as')
if (asIdx >= 0 && args[asIdx + 1]) {
dst = args[asIdx + 1]
}
try {
// Fetch the image from ComfyUI's output folder.
const viewUrl = api.apiURL(
`/view?filename=${encodeURIComponent(src)}&type=output`
)
const imgRes = await fetch(viewUrl)
if (!imgRes.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `copy-to-input: cannot read output/${src} (HTTP ${imgRes.status})`
}
}
const blob = await imgRes.blob()
// Upload into input/.
const form = new FormData()
form.append('image', blob, dst)
form.append('overwrite', 'true')
const uploadRes = await fetch(api.apiURL('/upload/image'), {
method: 'POST',
body: form
})
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => '')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `copy-to-input: upload failed (${uploadRes.status}) ${text.slice(0, 200)}`
}
}
const out = (await uploadRes.json()) as { name?: string }
return {
stdout: stringIter(`copied output/${src} → input/${out.name ?? dst}\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* latest-output-name — print the filename of the most recent SaveImage
* output. Convenience wrapper around latest-output so the LLM can grab
* just the name and pipe it into copy-to-input.
*/
const latestOutputName: Command = async () => {
try {
const res = await fetch(api.apiURL('/history'))
if (!res.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `latest-output-name: /history ${res.status}`
}
}
const history = (await res.json()) as Record<
string,
{
outputs?: Record<
string,
{
images?: Array<{
filename: string
subfolder?: string
type?: string
}>
}
>
}
>
const entries = Object.values(history)
for (const entry of entries.reverse()) {
const outs = entry.outputs ?? {}
for (const nodeOut of Object.values(outs)) {
const img = nodeOut.images?.[0]
if (img?.filename) {
const sub = img.subfolder ? img.subfolder + '/' : ''
return {
stdout: stringIter(sub + img.filename + '\n'),
exitCode: 0
}
}
}
}
return {
stdout: stringIter(''),
exitCode: 0,
stderr: '(no outputs in history)'
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
export function registerImageCommands(registry: CommandRegistry): void {
registry.register('copy-to-input', copyToInput)
registry.register('latest-output-name', latestOutputName)
}

View File

@@ -0,0 +1,277 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface ExternalModelEntry {
name: string
type?: string
base: string
save_path: string
filename: string
url: string
}
/**
* Fetch ComfyUI-Manager's curated model list and return a map from url →
* entry. Manager's whitelist check requires save_path+base+filename to match
* an entry; we lift those values from here automatically.
*/
async function fetchManagerModelList(): Promise<ExternalModelEntry[]> {
const res = await fetch(api.apiURL('/externalmodel/getlist?mode=cache'))
if (!res.ok) throw new Error(`externalmodel/getlist ${res.status}`)
const json = (await res.json()) as { models?: ExternalModelEntry[] }
return json.models ?? []
}
/**
* install-model <url> <saveAs>
* OR install-model --find <filename> (search the DB)
*
* Queue a model download in ComfyUI-Manager. <saveAs> is the target path
* relative to ComfyUI's models dir, e.g.:
* install-model https://huggingface.co/.../model.safetensors checkpoints/model.safetensors
*
* The command auto-fills required `base` and exact `save_path` from
* Manager's curated model list (/externalmodel/getlist). If the URL isn't
* recognised, installation will still be attempted with save_path=type,
* but the Manager whitelist may reject it.
*
* Requires ComfyUI-Manager. 404 → manager not available.
*/
const installModel: Command = async (ctx) => {
const args = ctx.argv.slice(1)
if (args[0] === '--find') {
const query = args.slice(1).join(' ').trim()
if (!query) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: install-model --find <filename-substring>'
}
}
try {
const models = await fetchManagerModelList()
const lower = query.toLowerCase()
const matches = models.filter(
(m) =>
m.filename?.toLowerCase().includes(lower) ||
m.name?.toLowerCase().includes(lower)
)
if (matches.length === 0) {
return {
stdout: stringIter('(no matches in manager model list)\n'),
exitCode: 0
}
}
const lines = matches
.slice(0, 20)
.map((m) => `${m.save_path}/${m.filename} [${m.base}]\n ${m.url}`)
lines.push(
'',
`${matches.length} match(es). Use the URL + save_path/filename shown.`
)
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
const [url, saveAs] = args
if (!url || !saveAs) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'usage: install-model <url> <save_path/filename>\n' +
' install-model --find <filename> # search Manager DB\n' +
' example: install-model https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors vae/SDXL/sdxl_vae.safetensors'
}
}
const lastSlash = saveAs.lastIndexOf('/')
if (lastSlash <= 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'install-model: <saveAs> must be of the form "<save_path>/<filename>"\n' +
' hint: install-model --find <filename> to look up the exact save_path'
}
}
const savePath = saveAs.slice(0, lastSlash)
const filename = saveAs.slice(lastSlash + 1)
const type = savePath.split('/')[0]
if (!filename) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'install-model: filename is empty'
}
}
// Auto-fill required `base` from Manager's curated list (whitelist check
// in manager_server.py requires save_path + base + filename match).
let base = 'Other'
try {
const models = await fetchManagerModelList()
const entry =
models.find((m) => m.url === url) ??
models.find((m) => m.filename === filename && m.save_path === savePath)
if (entry) base = entry.base
} catch {
/* Manager list unreachable — try anyway */
}
// Legacy endpoint. The v2 routes in the frontend's type schema are only
// present in manager-v4 (pip-installed); most deployments run main.
const uiId =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now())
const body = {
name: filename,
type,
base,
url,
filename,
save_path: savePath,
ui_id: uiId
}
try {
const res = await fetch(api.apiURL('/manager/queue/install_model'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (res.status === 404) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr:
'install-model: ComfyUI-Manager not available on this backend.\n' +
' The user must install it manually and restart ComfyUI.'
}
}
if (res.status === 403) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `install-model: rejected by security policy (403). URL may be on a deny list.`
}
}
if (!res.ok) {
const text = await res.text().catch(() => '')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `install-model: queue failed (${res.status}) ${text.slice(0, 200)}`
}
}
// Queue must be started after adding tasks (matches manager UI flow).
// Route is POST (legacy Manager) — GET returns 404.
const startRes = await fetch(api.apiURL('/manager/queue/start'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
const startOk = startRes.ok || startRes.status === 409 // 409 = already running
return {
stdout: stringIter(
`queued install of ${saveAs} from ${url}\n` +
` ui_id: ${uiId}\n` +
(startOk
? ' queue started — track with: install-status\n'
: ` WARNING: queue-start returned ${startRes.status}; task may not run\n`)
),
exitCode: 0
}
} catch (err) {
// A bare TypeError "Failed to fetch" almost always means the Manager
// route isn't registered (plugin missing) and the request never reached
// a real handler. Surface that explicitly so the user knows to install
// ComfyUI-Manager rather than debugging their network.
const msg = err instanceof Error ? err.message : String(err)
const hint = /Failed to fetch/i.test(msg)
? '\n hint: ComfyUI-Manager plugin is likely not installed on this backend.\n' +
' See: https://github.com/Comfy-Org/ComfyUI-Manager'
: ''
return {
stdout: emptyIter(),
exitCode: 1,
stderr: msg + hint
}
}
}
/**
* install-status
*
* Show the manager install queue: what's running, pending, and recent
* history. Useful right after install-model to watch progress.
*/
interface ManagerQueueStatus {
running_queue?: Array<{ ui_id: string; kind: string; params?: unknown }>
pending_queue?: Array<{ ui_id: string; kind: string; params?: unknown }>
}
const installStatus: Command = async () => {
try {
const statusRes = await fetch(api.apiURL('/manager/queue/status'))
if (statusRes.status === 404) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'install-status: ComfyUI-Manager not available on this backend.'
}
}
if (!statusRes.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `install-status: queue/status failed (${statusRes.status})`
}
}
const status = (await statusRes.json()) as ManagerQueueStatus & {
done_count?: number
in_progress_count?: number
is_processing?: boolean
}
const lines: string[] = []
lines.push(
`processing: ${status.is_processing ? 'yes' : 'no'}` +
` done: ${status.done_count ?? 0}` +
` in_progress: ${status.in_progress_count ?? 0}`
)
lines.push(`running: ${status.running_queue?.length ?? 0}`)
for (const t of status.running_queue ?? []) {
lines.push(` [${t.ui_id.slice(0, 8)}] ${t.kind}`)
}
lines.push(`pending: ${status.pending_queue?.length ?? 0}`)
for (const t of status.pending_queue ?? []) {
lines.push(` [${t.ui_id.slice(0, 8)}] ${t.kind}`)
}
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const hint = /Failed to fetch/i.test(msg)
? '\n hint: ComfyUI-Manager plugin is likely not installed on this backend.'
: ''
return {
stdout: emptyIter(),
exitCode: 1,
stderr: msg + hint
}
}
}
export function registerInstallCommands(registry: CommandRegistry): void {
registry.register('install-model', installModel)
registry.register('install-status', installStatus)
}

View File

@@ -0,0 +1,325 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* Snapshot current canvas state to ComfyUI's undo stack. Call AFTER a
* bulk mutation so Ctrl/Cmd+Z restores the pre-change layout in one step.
*/
function captureUndo(): void {
try {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch {
/* no-op: no workflow or tracker available */
}
}
/**
* Low-level primitives for managing node geometry on the active canvas.
*
* node-list [--filter <regex>] [--json]
* List nodes with: id, type, posX, posY, sizeW, sizeH, title.
* Tab-separated for easy piping; --json emits machine-readable form.
*
* node-pos <id> → prints 'x y'
* node-pos <id> <x> <y> → sets position
*
* node-size <id> → prints 'w h'
* node-size <id> <w> <h> → sets size
*
* graph-links [--filter <id>]
* List links: id, from-node:from-slot, to-node:to-slot, type.
* Useful for the LLM to compute its own topological / tree layouts.
*
* canvas-redraw
* Trigger a repaint after bulk geometry changes.
*
* With these primitives the agent can implement any layout algorithm
* (tree, dagre, spring, grid, …) entirely in the shell or via run-js.
*/
interface LNode {
id: number
type?: string
comfyClass?: string
title?: string
pos: [number, number]
size: [number, number]
}
interface LLink {
id: number
origin_id: number
origin_slot: number
target_id: number
target_slot: number
type?: string
}
interface LGraphLike {
_nodes: LNode[]
links: Record<number, LLink> | LLink[] | Map<number, LLink>
setDirtyCanvas?: (fg: boolean, bg: boolean) => void
}
function getGraph(): LGraphLike | null {
const g = useCanvasStore().canvas?.graph as LGraphLike | undefined
return g ?? null
}
function iterateLinks(links: LGraphLike['links']): LLink[] {
if (Array.isArray(links)) return links.filter(Boolean)
if (links instanceof Map) return [...links.values()]
return Object.values(links ?? {}).filter((l): l is LLink => !!l)
}
function findNode(g: LGraphLike, id: string): LNode | undefined {
return g._nodes.find((n) => String(n.id) === id)
}
const nodeList: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const filterArg = ctx.argv.find((a) => a.startsWith('--filter='))
const re = filterArg ? new RegExp(filterArg.slice(9), 'i') : null
const json = ctx.argv.includes('--json')
const rows = g._nodes
.filter((n) => !re || re.test(n.comfyClass ?? n.type ?? ''))
.map((n) => ({
id: n.id,
type: n.comfyClass ?? n.type ?? 'Unknown',
x: Math.round(n.pos?.[0] ?? 0),
y: Math.round(n.pos?.[1] ?? 0),
w: Math.round(n.size?.[0] ?? 0),
h: Math.round(n.size?.[1] ?? 0),
title: n.title ?? ''
}))
if (json) {
return {
stdout: stringIter(JSON.stringify(rows, null, 2) + '\n'),
exitCode: 0
}
}
const lines = [
'id\ttype\tx\ty\tw\th\ttitle',
...rows.map(
(r) => `${r.id}\t${r.type}\t${r.x}\t${r.y}\t${r.w}\t${r.h}\t${r.title}`
)
]
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
const nodePos: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const [, id, xArg, yArg] = ctx.argv
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-pos <id> [<x> <y>]'
}
}
const n = findNode(g, id)
if (!n) return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${id}` }
if (xArg === undefined) {
return {
stdout: stringIter(`${Math.round(n.pos[0])} ${Math.round(n.pos[1])}\n`),
exitCode: 0
}
}
const x = Number(xArg)
const y = Number(yArg)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'x and y must be numbers'
}
}
n.pos = [x, y]
g.setDirtyCanvas?.(true, true)
captureUndo()
return { stdout: stringIter(`set ${id} pos=${x},${y}\n`), exitCode: 0 }
}
const nodeSize: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const [, id, wArg, hArg] = ctx.argv
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-size <id> [<w> <h>]'
}
}
const n = findNode(g, id)
if (!n) return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${id}` }
if (wArg === undefined) {
return {
stdout: stringIter(`${Math.round(n.size[0])} ${Math.round(n.size[1])}\n`),
exitCode: 0
}
}
const w = Number(wArg)
const h = Number(hArg)
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'w and h must be positive numbers'
}
}
n.size = [w, h]
g.setDirtyCanvas?.(true, true)
captureUndo()
return { stdout: stringIter(`set ${id} size=${w}x${h}\n`), exitCode: 0 }
}
const graphLinks: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const filterArg = ctx.argv.find((a) => a.startsWith('--filter='))
const nodeFilter = filterArg ? filterArg.slice(9) : null
const rows = iterateLinks(g.links)
.filter((l) =>
nodeFilter
? String(l.origin_id) === nodeFilter ||
String(l.target_id) === nodeFilter
: true
)
.map(
(l) =>
`${l.id}\t${l.origin_id}:${l.origin_slot}\t→\t${l.target_id}:${l.target_slot}\t${l.type ?? ''}`
)
const header = 'link\tfrom\t\tto\ttype'
return {
stdout: stringIter([header, ...rows].join('\n') + '\n'),
exitCode: 0
}
}
const canvasRedraw: Command = async () => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
g.setDirtyCanvas?.(true, true)
return { stdout: stringIter('canvas redrawn\n'), exitCode: 0 }
}
/**
* graph-dot — emit a DOT-like text description of the graph. Nodes are
* labelled by id and type, with size and current position. Directed edges
* follow slot-to-slot links. This is a compact, human/LLM-readable view
* the agent can use as input when reasoning about a layout.
*/
const graphDot: Command = async () => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const lines: string[] = []
lines.push('digraph graph {')
lines.push(' rankdir=TB;')
for (const n of g._nodes) {
const type = n.comfyClass ?? n.type ?? 'Unknown'
const x = Math.round(n.pos?.[0] ?? 0)
const y = Math.round(n.pos?.[1] ?? 0)
const w = Math.round(n.size?.[0] ?? 0)
const h = Math.round(n.size?.[1] ?? 0)
lines.push(` ${n.id} [label="${type}" pos="${x},${y}" size="${w}x${h}"];`)
}
for (const l of iterateLinks(g.links)) {
lines.push(` ${l.origin_id} -> ${l.target_id};`)
}
lines.push('}')
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
/**
* apply-layout — accept JSON (from stdin or arg) describing bulk
* position / size updates. Shape:
* [{"id": 3, "pos": [100, 100], "size": [240, 160]}, ...]
* Unknown ids are skipped. One redraw at the end.
*/
const applyLayout: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
let input = ''
const inline = ctx.argv.slice(1).join(' ').trim()
if (inline) input = inline
else {
const chunks: string[] = []
for await (const c of ctx.stdin) chunks.push(c)
input = chunks.join('')
}
if (!input.trim()) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: apply-layout <json> | echo <json> | apply-layout'
}
}
let parsed: unknown
try {
parsed = JSON.parse(input)
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'invalid JSON: ' + (err instanceof Error ? err.message : String(err))
}
}
if (!Array.isArray(parsed)) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'expected JSON array of {id, pos?, size?}'
}
}
let updated = 0
let skipped = 0
for (const item of parsed as Array<{
id?: number | string
pos?: [number, number]
size?: [number, number]
}>) {
if (item?.id === undefined) {
skipped++
continue
}
const n = findNode(g, String(item.id))
if (!n) {
skipped++
continue
}
if (Array.isArray(item.pos) && item.pos.length === 2) {
const [x, y] = item.pos
if (Number.isFinite(x) && Number.isFinite(y)) n.pos = [x, y]
}
if (Array.isArray(item.size) && item.size.length === 2) {
const [w, h] = item.size
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0)
n.size = [w, h]
}
updated++
}
g.setDirtyCanvas?.(true, true)
captureUndo()
return {
stdout: stringIter(
`applied: ${updated} nodes, skipped: ${skipped} — Ctrl/Cmd+Z to undo\n`
),
exitCode: 0
}
}
export function registerLayoutCommands(registry: CommandRegistry): void {
registry.register('node-list', nodeList)
registry.register('node-pos', nodePos)
registry.register('node-size', nodeSize)
registry.register('graph-links', graphLinks)
registry.register('graph-dot', graphDot)
registry.register('apply-layout', applyLayout)
registry.register('canvas-redraw', canvasRedraw)
}

View File

@@ -0,0 +1,858 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { useCommandStore } from '@/stores/commandStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface PosSizeNode {
id: number | string
pos: [number, number]
size: [number, number]
comfyClass?: string
type?: string
}
function captureUndo(): void {
try {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch {
/* no-op */
}
}
function getCanvas() {
return useCanvasStore().canvas
}
function getGraph() {
return getCanvas()?.graph ?? null
}
function getSelectedNodes(): PosSizeNode[] {
const canvas = getCanvas()
if (!canvas) return []
const selected = (canvas as { selected_nodes?: Record<string, PosSizeNode> })
.selected_nodes
if (!selected) return []
return Object.values(selected)
}
/**
* node-search <pattern>
*
* Returns matching node type names from LiteGraph.registered_node_types.
* Case-insensitive substring or regex match. One per line, sorted.
*/
const nodeSearch: Command = async (ctx) => {
const pattern = ctx.argv.slice(1).join(' ').trim()
if (!pattern) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-search <pattern>'
}
}
const registered = LiteGraph.registered_node_types ?? {}
let regex: RegExp
try {
regex = new RegExp(pattern, 'i')
} catch {
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
regex = new RegExp(escaped, 'i')
}
const matches = Object.keys(registered)
.filter((type) => regex.test(type))
.sort()
if (matches.length === 0) {
return { stdout: stringIter(''), exitCode: 0 }
}
return {
stdout: stringIter(matches.join('\n') + '\n'),
exitCode: 0
}
}
/**
* add-node <type> [x] [y]
*
* Create a node of the given registered type and add it to the active
* graph. Positions at [x, y] (default [100, 100]). Prints the new node id.
*/
interface ViewportCanvas {
ds?: { offset: [number, number]; scale: number }
canvas?: { width: number; height: number }
}
/**
* Pick a non-overlapping position near the viewport center. Scans outward
* in a spiral grid until it finds a cell that doesn't intersect any
* existing node's AABB. Returns the top-left for the new node.
*/
function pickEmptySpot(
graph: { _nodes?: PosSizeNode[] },
canvas: ViewportCanvas,
nodeSize: [number, number] = [220, 100]
): [number, number] {
const nodes = graph._nodes ?? []
const ds = canvas.ds
const vp = canvas.canvas
let centerX = 0
let centerY = 0
if (ds && vp) {
// Viewport center in graph coords: (-offset + viewport/2) / scale
centerX = (-ds.offset[0] + vp.width / 2) / ds.scale
centerY = (-ds.offset[1] + vp.height / 2) / ds.scale
} else if (nodes.length > 0) {
centerX = nodes.reduce((s, n) => s + n.pos[0], 0) / nodes.length
centerY = nodes.reduce((s, n) => s + n.pos[1], 0) / nodes.length
}
const [w, h] = nodeSize
const pad = 40
const stepX = w + pad
const stepY = h + pad
const overlaps = (x: number, y: number): boolean =>
nodes.some((n) => {
const [nx, ny] = n.pos
const [nw, nh] = n.size ?? [220, 100]
return !(x + w < nx || nx + nw < x || y + h < ny || ny + nh < y)
})
const origin: [number, number] = [centerX - w / 2, centerY - h / 2]
if (!overlaps(origin[0], origin[1])) return origin
// Spiral outward: rings of radius r, check each grid cell.
for (let r = 1; r < 40; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue
const x = origin[0] + dx * stepX
const y = origin[1] + dy * stepY
if (!overlaps(x, y)) return [x, y]
}
}
}
return origin
}
const addNode: Command = async (ctx) => {
const [, typeArg, xArg, yArg] = ctx.argv
if (!typeArg) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: add-node <type> [x] [y]'
}
}
const graph = getGraph()
if (!graph) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
if (!LiteGraph.registered_node_types?.[typeArg]) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `add-node: unknown type "${typeArg}" — try: node-search <pattern>`
}
}
const xyGiven = xArg !== undefined && yArg !== undefined
const x = xArg !== undefined ? Number(xArg) : Number.NaN
const y = yArg !== undefined ? Number(yArg) : Number.NaN
if (xyGiven && (!Number.isFinite(x) || !Number.isFinite(y))) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'add-node: x and y must be numbers'
}
}
try {
const node = LiteGraph.createNode(typeArg)
if (!node) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `add-node: failed to create node of type "${typeArg}"`
}
}
if (xyGiven) {
node.pos = [x, y]
} else {
const canvas = getCanvas() as unknown as ViewportCanvas
const pos = pickEmptySpot(
graph as { _nodes?: PosSizeNode[] },
canvas,
(node as { size?: [number, number] }).size ?? [220, 100]
)
node.pos = pos
}
;(graph as { add: (n: unknown) => void }).add(node)
getCanvas()?.setDirty(true, true)
captureUndo()
return {
stdout: stringIter(`${(node as { id: number | string }).id}\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
type AlignAxis = 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y'
const ALIGN_AXES: readonly AlignAxis[] = [
'left',
'right',
'center-x',
'top',
'bottom',
'center-y'
]
/**
* align-nodes <axis>
*
* Align currently-selected nodes to a common edge/center on the given axis.
* Axis: left | right | center-x | top | bottom | center-y
*/
const alignNodes: Command = async (ctx) => {
const axis = ctx.argv[1] as AlignAxis | undefined
if (!axis || !ALIGN_AXES.includes(axis)) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `usage: align-nodes <${ALIGN_AXES.join('|')}>`
}
}
const selected = getSelectedNodes()
if (selected.length < 2) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'align-nodes: select at least 2 nodes'
}
}
const xs = selected.map((n) => n.pos[0])
const ys = selected.map((n) => n.pos[1])
const rights = selected.map((n) => n.pos[0] + (n.size?.[0] ?? 0))
const bottoms = selected.map((n) => n.pos[1] + (n.size?.[1] ?? 0))
for (const n of selected) {
const w = n.size?.[0] ?? 0
const h = n.size?.[1] ?? 0
if (axis === 'left') n.pos[0] = Math.min(...xs)
else if (axis === 'right') n.pos[0] = Math.max(...rights) - w
else if (axis === 'center-x') {
const cx =
(Math.min(...xs) +
Math.max(...selected.map((s) => s.pos[0] + (s.size?.[0] ?? 0)))) /
2
n.pos[0] = cx - w / 2
} else if (axis === 'top') n.pos[1] = Math.min(...ys)
else if (axis === 'bottom') n.pos[1] = Math.max(...bottoms) - h
else if (axis === 'center-y') {
const cy =
(Math.min(...ys) +
Math.max(...selected.map((s) => s.pos[1] + (s.size?.[1] ?? 0)))) /
2
n.pos[1] = cy - h / 2
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
return {
stdout: stringIter(`aligned ${selected.length} nodes (${axis})\n`),
exitCode: 0
}
}
/**
* distribute-nodes <h|v>
*
* Distribute selected nodes evenly along horizontal (h) or vertical (v)
* axis between the first and last node's current positions.
*/
const distributeNodes: Command = async (ctx) => {
const axis = ctx.argv[1]
if (axis !== 'h' && axis !== 'v') {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: distribute-nodes <h|v>'
}
}
const selected = getSelectedNodes()
if (selected.length < 3) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'distribute-nodes: select at least 3 nodes'
}
}
const dim = axis === 'h' ? 0 : 1
const sorted = [...selected].sort((a, b) => a.pos[dim] - b.pos[dim])
const first = sorted[0].pos[dim]
const last = sorted[sorted.length - 1].pos[dim]
const step = (last - first) / (sorted.length - 1)
sorted.forEach((n, i) => {
n.pos[dim] = first + step * i
})
getCanvas()?.setDirty(true, true)
captureUndo()
return {
stdout: stringIter(
`distributed ${sorted.length} nodes along ${axis === 'h' ? 'horizontal' : 'vertical'}\n`
),
exitCode: 0
}
}
/**
* toggle-panel <name>
*
* Open/close a right-side or left-side sidebar tab by name.
*
* Right-side panel tabs: parameters | nodes | settings | info | subgraph | errors
* Left-side sidebar tabs: whatever is registered (queue, history, assets, workflows, models, node-library, apps)
*/
const RIGHT_TABS: readonly RightSidePanelTab[] = [
'parameters',
'nodes',
'settings',
'info',
'subgraph',
'errors'
]
const togglePanel: Command = async (ctx) => {
const name = ctx.argv[1]?.trim().toLowerCase()
if (!name) {
const right = `right: ${RIGHT_TABS.join(', ')}`
const leftTabs = useSidebarTabStore()
.sidebarTabs.map((t) => t.id)
.join(', ')
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `usage: toggle-panel <name>\n ${right}\n left (sidebar): ${leftTabs}`
}
}
// Queue + history are command-driven overlays, not sidebar tabs — route
// them through the command store so the user's mental model ("open the
// queue panel") still works.
const overlayCommands: Record<string, string> = {
queue: 'Comfy.Queue.ToggleOverlay',
history: 'Comfy.Queue.ToggleOverlay',
'job-history': 'Comfy.Queue.ToggleOverlay'
}
if (overlayCommands[name]) {
try {
await useCommandStore().execute(overlayCommands[name])
return {
stdout: stringIter(`toggled ${name} overlay\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
// Alias common names to panel/sidebar ids. Only alias names that we know
// map to a real registered tab id in this build.
const aliases: Record<string, string> = {
'missing-models': 'errors',
'model-library': 'models',
'node-library': 'node-library'
}
const resolved = aliases[name] ?? name
if ((RIGHT_TABS as readonly string[]).includes(resolved)) {
const store = useRightSidePanelStore()
const isSame = store.activeTab === resolved && store.isOpen
if (isSame) {
store.closePanel()
return {
stdout: stringIter(`closed right panel (${resolved})\n`),
exitCode: 0
}
}
store.openPanel(resolved as RightSidePanelTab)
return {
stdout: stringIter(`opened right panel (${resolved})\n`),
exitCode: 0
}
}
const sidebar = useSidebarTabStore()
const tab = sidebar.sidebarTabs.find((t) => t.id === resolved)
if (!tab) {
const known = sidebar.sidebarTabs.map((t) => t.id).join(', ')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `toggle-panel: unknown panel "${name}"\n right: ${RIGHT_TABS.join(', ')}\n sidebar: ${known}`
}
}
sidebar.toggleSidebarTab(tab.id)
const nowActive = sidebar.activeSidebarTabId === tab.id
return {
stdout: stringIter(
`${nowActive ? 'opened' : 'closed'} sidebar tab (${tab.id})\n`
),
exitCode: 0
}
}
/**
* select <idOrSpec...>
*
* Select one or more nodes. Accepts:
* - node ids: select 3 5 7
* - type filter: select type=KSampler
* - "all": select all
* - "none": select none (clears)
*
* Needed before align-nodes / distribute-nodes.
*/
interface CanvasWithSelection {
selected_nodes: Record<string, unknown>
selectNode?: (node: unknown, keep?: boolean) => void
deselectAllNodes?: () => void
setDirty?: (a: boolean, b: boolean) => void
}
const selectCmd: Command = async (ctx) => {
const args = ctx.argv.slice(1)
if (args.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: select <id...> | type=<Type> | all | none'
}
}
const graph = getGraph() as { _nodes?: unknown[] } | null
const canvas = getCanvas() as unknown as CanvasWithSelection | null
if (!graph?._nodes || !canvas) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
canvas.deselectAllNodes?.()
const nodes = graph._nodes as Array<{ id: number; type?: string }>
let picked: typeof nodes = []
if (args[0] === 'none') {
canvas.setDirty?.(true, true)
return { stdout: stringIter('selection cleared\n'), exitCode: 0 }
}
if (args[0] === 'all') {
picked = nodes
} else {
for (const a of args) {
if (a.startsWith('type=')) {
const t = a.slice(5)
picked.push(...nodes.filter((n) => n.type === t))
} else if (/^\d+$/.test(a)) {
const id = Number(a)
const n = nodes.find((node) => node.id === id)
if (n) picked.push(n)
} else {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `select: unrecognised token "${a}" (expected id, type=X, all, or none)`
}
}
}
}
for (const n of picked) canvas.selectNode?.(n, true)
canvas.setDirty?.(true, true)
return {
stdout: stringIter(
`selected ${picked.length} node${picked.length === 1 ? '' : 's'}: ${picked
.map((n) => n.id)
.join(', ')}\n`
),
exitCode: 0
}
}
/**
* connect <fromId>.<output> <toId>.<input>
*
* Create a link. output/input may be the socket index (0-based) or name.
* Example:
* connect 3.0 5.0 # first output of node 3 → first input of 5
* connect 3.LATENT 5.samples # by socket name
*/
interface LinkableNode {
id: number
outputs?: Array<{ name?: string }>
inputs?: Array<{ name?: string }>
connect: (fromSlot: number, target: LinkableNode, toSlot: number) => unknown
}
function resolveSlot(
socket: string,
slots: Array<{ name?: string }> | undefined
): number | null {
if (!slots) return null
if (/^\d+$/.test(socket)) {
const i = Number(socket)
return i >= 0 && i < slots.length ? i : null
}
const idx = slots.findIndex((s) => s.name === socket)
return idx >= 0 ? idx : null
}
const connectCmd: Command = async (ctx) => {
const [, from, to] = ctx.argv
if (!from || !to) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: connect <fromId>.<output> <toId>.<input>'
}
}
const fromMatch = from.match(/^(\d+)\.(.+)$/)
const toMatch = to.match(/^(\d+)\.(.+)$/)
if (!fromMatch || !toMatch) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'connect: both args must be <id>.<socket>'
}
}
const graph = getGraph() as { _nodes?: LinkableNode[] } | null
const nodes = graph?._nodes
if (!nodes) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const fromNode = nodes.find((n) => n.id === Number(fromMatch[1]))
const toNode = nodes.find((n) => n.id === Number(toMatch[1]))
if (!fromNode || !toNode) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `connect: node not found (${!fromNode ? fromMatch[1] : toMatch[1]})`
}
}
const fromSlot = resolveSlot(fromMatch[2], fromNode.outputs)
const toSlot = resolveSlot(toMatch[2], toNode.inputs)
if (fromSlot === null || toSlot === null) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `connect: socket not found (from=${fromMatch[2]} to=${toMatch[2]})`
}
}
try {
const link = fromNode.connect(fromSlot, toNode, toSlot)
if (!link) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'connect: link rejected (type mismatch?)'
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
// Auto-layout after successful connect so the canvas stays readable.
// Opt out with --no-layout for users hand-placing nodes.
const suppress = ctx.argv.includes('--no-layout')
let extra = ''
if (!suppress) {
extra = ' + ' + runLayout('lr')
}
return {
stdout: stringIter(
`connected ${fromNode.id}.${fromMatch[2]}${toNode.id}.${toMatch[2]}${extra}\n`
),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* layout [lr|tb]
*
* Topological tree layout of the active graph. `lr` = left-to-right (default,
* natural for ComfyUI pipelines); `tb` = top-to-bottom. Uses longest-path
* levelling with stable within-level ordering by id. Captures undo.
*/
interface LayoutNode {
id: number
pos: [number, number]
size?: [number, number]
}
interface LayoutLink {
origin_id: number
target_id: number
}
function runLayout(direction: 'lr' | 'tb' = 'lr'): string {
const graph = getGraph() as {
_nodes?: LayoutNode[]
links?: LayoutLink[] | Record<string, LayoutLink>
} | null
const nodes = graph?._nodes
if (!nodes || nodes.length === 0) return 'layout: nothing to do'
const rawLinks = graph?.links
const links: LayoutLink[] = Array.isArray(rawLinks)
? rawLinks.filter(Boolean)
: Object.values(rawLinks ?? {}).filter(Boolean)
const parents = new Map<number, Set<number>>()
for (const n of nodes) parents.set(n.id, new Set())
for (const l of links) parents.get(l.target_id)?.add(l.origin_id)
const lvl = new Map<number, number>()
for (const n of nodes) lvl.set(n.id, 0)
let changed = true
let guard = nodes.length * 2
while (changed && guard-- > 0) {
changed = false
for (const n of nodes) {
let m = -1
for (const p of parents.get(n.id) ?? []) m = Math.max(m, lvl.get(p) ?? 0)
if (m + 1 > (lvl.get(n.id) ?? 0)) {
lvl.set(n.id, m + 1)
changed = true
}
}
}
const byLv = new Map<number, LayoutNode[]>()
for (const n of nodes) {
const k = lvl.get(n.id) ?? 0
if (!byLv.has(k)) byLv.set(k, [])
byLv.get(k)?.push(n)
}
const keys = [...byLv.keys()].sort((a, b) => a - b)
if (direction === 'lr') {
let x = 60
for (const k of keys) {
const col = (byLv.get(k) ?? []).sort((a, b) => a.id - b.id)
let y = 60
let maxW = 0
for (const n of col) {
n.pos = [x, y]
y += (n.size?.[1] ?? 100) + 40
maxW = Math.max(maxW, n.size?.[0] ?? 220)
}
x += maxW + 60
}
} else {
let y = 60
for (const k of keys) {
const row = (byLv.get(k) ?? []).sort((a, b) => a.id - b.id)
let x = 60
let maxH = 0
for (const n of row) {
n.pos = [x, y]
x += (n.size?.[0] ?? 220) + 40
maxH = Math.max(maxH, n.size?.[1] ?? 100)
}
y += maxH + 60
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
return `laid out ${nodes.length} nodes (${direction})`
}
const layoutCmd: Command = async (ctx) => {
const dir = (ctx.argv[1] ?? 'lr').toLowerCase()
if (dir !== 'lr' && dir !== 'tb') {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: layout [lr|tb]'
}
}
return { stdout: stringIter(runLayout(dir) + '\n'), exitCode: 0 }
}
/**
* disconnect <id>.<input>
*
* Remove the link feeding a specific input socket. Auto-layouts afterwards
* (opt out with --no-layout). To clear multiple, call repeatedly.
*/
interface DisconnectableNode {
id: number
inputs?: Array<{ name?: string }>
disconnectInput: (slot: number) => boolean
}
const disconnectCmd: Command = async (ctx) => {
const target = ctx.argv[1]
if (!target) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: disconnect <id>.<input> [--no-layout]'
}
}
const match = target.match(/^(\d+)\.(.+)$/)
if (!match) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'disconnect: arg must be <id>.<socket>'
}
}
const graph = getGraph() as { _nodes?: DisconnectableNode[] } | null
const node = graph?._nodes?.find((n) => n.id === Number(match[1]))
if (!node) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `disconnect: no node ${match[1]}`
}
}
const slot = resolveSlot(match[2], node.inputs)
if (slot === null) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `disconnect: unknown input "${match[2]}" on node ${node.id}`
}
}
const ok = node.disconnectInput(slot)
getCanvas()?.setDirty(true, true)
captureUndo()
const suppress = ctx.argv.includes('--no-layout')
const extra = !suppress ? ' + ' + runLayout('lr') : ''
return {
stdout: stringIter(
ok
? `disconnected ${node.id}.${match[2]}${extra}\n`
: `disconnect: ${node.id}.${match[2]} was not connected\n`
),
exitCode: 0
}
}
/**
* remove-node <id...>
*
* Delete one or more nodes from the active graph. Auto-layouts after.
*/
interface RemovableGraph {
_nodes?: Array<{ id: number }>
remove: (node: unknown) => void
}
const removeNode: Command = async (ctx) => {
const ids = ctx.argv
.slice(1)
.filter((a) => !a.startsWith('--'))
.map((a) => Number(a))
.filter((n) => Number.isFinite(n))
if (ids.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: remove-node <id...> [--no-layout]'
}
}
const graph = getGraph() as unknown as RemovableGraph | null
if (!graph?._nodes) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const removed: number[] = []
for (const id of ids) {
const n = graph._nodes.find((x) => x.id === id)
if (n) {
graph.remove(n)
removed.push(id)
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
const suppress = ctx.argv.includes('--no-layout')
const extra = !suppress && removed.length > 0 ? ' + ' + runLayout('lr') : ''
return {
stdout: stringIter(
`removed ${removed.length} node(s): ${removed.join(', ')}${extra}\n`
),
exitCode: 0
}
}
/**
* get-widget <id> <name>
*
* Read a widget's current value. Complements set-widget.
*/
interface WidgetCarrier {
id: number
widgets?: Array<{ name?: string; value?: unknown }>
}
const getWidget: Command = async (ctx) => {
const [, idArg, nameArg] = ctx.argv
if (!idArg || !nameArg) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: get-widget <id> <name>'
}
}
const graph = getGraph() as { _nodes?: WidgetCarrier[] } | null
const node = graph?._nodes?.find((n) => n.id === Number(idArg))
if (!node) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `get-widget: no node ${idArg}`
}
}
const widget = node.widgets?.find((w) => w.name === nameArg)
if (!widget) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `get-widget: no widget "${nameArg}" on node ${idArg}`
}
}
return {
stdout: stringIter(JSON.stringify(widget.value) + '\n'),
exitCode: 0
}
}
export function registerNodeOpsCommands(registry: CommandRegistry): void {
registry.register('node-search', nodeSearch)
registry.register('add-node', addNode)
registry.register('align-nodes', alignNodes)
registry.register('distribute-nodes', distributeNodes)
registry.register('toggle-panel', togglePanel)
registry.register('select', selectCmd)
registry.register('connect', connectCmd)
registry.register('get-widget', getWidget)
registry.register('layout', layoutCmd)
registry.register('disconnect', disconnectCmd)
registry.register('remove-node', removeNode)
}

View File

@@ -0,0 +1,176 @@
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import type { components } from '@comfyorg/registry-types'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
type Pack = components['schemas']['Node']
type ComfyNode = components['schemas']['ComfyNode']
const DEFAULT_LIMIT = 20
function packLine(p: Pack): string {
const id = p.id ?? '?'
const ver = p.latest_version?.version ?? 'unknown'
const name = p.name ?? id
const desc = (p.description ?? '').replace(/\s+/g, ' ').slice(0, 80)
return `${id}@${ver} ${name}${desc ? ' — ' + desc : ''}`
}
function nodeLine(n: ComfyNode): string {
const name = n.comfy_node_name ?? '?'
const cat = n.category ?? ''
const desc = (n.description ?? '').replace(/\s+/g, ' ').slice(0, 80)
const tail = [cat, desc].filter(Boolean).join(' — ')
return tail ? `${name} (${tail})` : name
}
/**
* node-search-registry <pattern>
*
* Search the public Comfy Registry for node-classes matching <pattern>
* across ALL published custom-node packs — including ones the user has
* not installed locally. Use this when local `node-search` returns no
* results: the node may exist in a pack that hasn't been installed yet.
*
* Output: one pack per line with install hint underneath.
*/
const nodeSearchRegistry: Command = async (ctx) => {
const pattern = ctx.argv.slice(1).join(' ').trim()
if (!pattern) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-search-registry <pattern>'
}
}
const svc = useComfyRegistryService()
const res = await svc.search({
comfy_node_search: pattern,
limit: DEFAULT_LIMIT
})
const packs = res?.nodes ?? []
if (packs.length === 0) {
return {
stdout: stringIter(
`no registry packs expose a node matching "${pattern}".\n` +
'note: registry only indexes published packs. Try `pack-search ' +
pattern +
'` for pack-name/description match, or fall back to a github repo search.\n'
),
exitCode: 0
}
}
const total = res?.total ?? packs.length
const lines = packs.map(
(p) => packLine(p) + (p.id ? '\n inspect: pack-info ' + p.id : '')
)
const header =
packs.length < total
? `${packs.length} of ${total} pack(s) expose a node matching "${pattern}". To install one, ask the user to use ComfyUI-Manager (Settings → Extensions) — there is no shell command for pack installs yet.\n`
: `${packs.length} pack(s) expose a node matching "${pattern}". To install one, ask the user to use ComfyUI-Manager (Settings → Extensions) — there is no shell command for pack installs yet.\n`
return {
stdout: stringIter(header + lines.join('\n') + '\n'),
exitCode: 0
}
}
/**
* pack-search <pattern>
*
* Search the public Comfy Registry for packs whose name or description
* matches <pattern>. Complements `node-search-registry` (which matches
* node-class names) — use this when looking for a pack by topic rather
* than by a specific node-class.
*/
const packSearch: Command = async (ctx) => {
const pattern = ctx.argv.slice(1).join(' ').trim()
if (!pattern) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: pack-search <pattern>'
}
}
const svc = useComfyRegistryService()
const res = await svc.search({ search: pattern, limit: DEFAULT_LIMIT })
const packs = res?.nodes ?? []
if (packs.length === 0) {
return {
stdout: stringIter(`no registry packs match "${pattern}".\n`),
exitCode: 0
}
}
const total = res?.total ?? packs.length
const header =
packs.length < total
? `${packs.length} of ${total} pack(s) match "${pattern}":\n`
: `${packs.length} pack(s) match "${pattern}":\n`
const lines = packs.map(
(p) => packLine(p) + (p.id ? '\n inspect: pack-info ' + p.id : '')
)
return {
stdout: stringIter(header + lines.join('\n') + '\n'),
exitCode: 0
}
}
/**
* pack-info <pack_id>
*
* List all node-classes provided by <pack_id>'s latest version. Use this
* to verify a pack actually contains the node you want before installing
* it — registry node-search returns the pack, but not the full node list.
*/
const packInfo: Command = async (ctx) => {
const packId = ctx.argv[1]?.trim()
if (!packId) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: pack-info <pack_id>'
}
}
const svc = useComfyRegistryService()
const pack = await svc.getPackById(packId)
if (!pack) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `pack-info: pack "${packId}" not found in registry`
}
}
const version = pack.latest_version?.version
if (!version) {
return {
stdout: stringIter(packLine(pack) + '\n (no published version)\n'),
exitCode: 0
}
}
const defs = await svc.getNodeDefs({ packId, version })
const nodes = defs?.comfy_nodes ?? []
const head = packLine(pack)
if (nodes.length === 0) {
return {
stdout: stringIter(head + '\n (this pack publishes no node defs)\n'),
exitCode: 0
}
}
return {
stdout: stringIter(
head +
`\nnodes (${nodes.length}):\n` +
nodes.map((n) => ' ' + nodeLine(n)).join('\n') +
'\n'
),
exitCode: 0
}
}
export function registerRegistrySearchCommands(
registry: CommandRegistry
): void {
registry.register('node-search-registry', nodeSearchRegistry)
registry.register('pack-search', packSearch)
registry.register('pack-info', packInfo)
}

View File

@@ -0,0 +1,175 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* see [<question>]
*
* Capture the visible canvas (LiteGraph node graph) as a PNG, upload to
* ComfyUI's input/ folder, and feed to Gemini 3.1 Pro for analysis.
* Default question asks Gemini to describe what's on the canvas — useful
* after a workflow run to confirm a Preview3D / PreviewImage actually
* rendered, or to spot disconnected nodes / red error frames.
*
* Returns Gemini's text response. Requires Comfy Cloud auth (validate
* uses the same auth flow).
*
* NOTE: Preview3D / Preview Audio render their own internal canvases,
* which the main LiteGraph capture does not include. To inspect those,
* pair `see` with the relevant filename via `validate <file>`.
*/
const see: Command = async (ctx) => {
const question =
ctx.argv.slice(1).join(' ').trim() ||
'Describe what is visible on this ComfyUI canvas: what workflow is loaded, what node types are present, are any nodes showing errors or disconnected sockets, are there any visible image/3D previews?'
// Find the LiteGraph canvas — the main node-graph rendering surface.
const canvas = document.querySelector(
'canvas#graph-canvas, canvas.litegraph, .agent-xterm-panel + * canvas, body > canvas'
) as HTMLCanvasElement | null
const liteCanvas =
canvas ??
(Array.from(document.querySelectorAll('canvas')).find(
(c) => c.width > 200 && c.height > 200
) as HTMLCanvasElement | undefined)
if (!liteCanvas) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'see: could not locate the canvas element'
}
}
// Capture as PNG blob.
const blob = await new Promise<Blob | null>((resolve) =>
liteCanvas.toBlob((b) => resolve(b), 'image/png')
)
if (!blob) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr:
'see: canvas toBlob returned null (likely tainted by cross-origin content)'
}
}
// Upload to input/ under a stable agent-staging subfolder so we can use
// it as a LoadImage source for Gemini.
const ts = Date.now()
const filename = `agent-see-${ts}.png`
try {
const form = new FormData()
form.append('image', blob, filename)
form.append('subfolder', 'agent-see')
form.append('overwrite', 'true')
const up = await fetch(api.apiURL('/upload/image'), {
method: 'POST',
body: form
})
if (!up.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `see: upload failed (${up.status})`
}
}
const upJson = (await up.json()) as { name?: string; subfolder?: string }
const stagedPath = upJson.subfolder
? `${upJson.subfolder}/${upJson.name}`
: (upJson.name ?? filename)
// Submit a Gemini-only prompt with PreviewAny so the response isn't
// culled (GeminiNode is api but not OUTPUT_NODE).
const prompt = {
prompt: {
'1': { class_type: 'LoadImage', inputs: { image: stagedPath } },
'2': {
class_type: 'GeminiNode',
inputs: {
prompt: question,
model: 'gemini-3-1-pro',
seed: 1,
images: ['1', 0]
}
},
'3': { class_type: 'PreviewAny', inputs: { source: ['2', 0] } }
},
client_id: 'sno-agent-see'
}
const queueRes = await fetch(api.apiURL('/prompt'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prompt)
})
if (!queueRes.ok) {
const text = await queueRes.text().catch(() => '')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `see: queue failed (${queueRes.status}) ${text.slice(0, 300)}`
}
}
const queued = (await queueRes.json()) as { prompt_id?: string }
const pid = queued.prompt_id
if (!pid) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'see: queue did not return prompt_id'
}
}
// Poll history (Gemini ~5-10s).
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const hRes = await fetch(api.apiURL(`/history/${pid}`))
if (hRes.ok) {
const hJson = (await hRes.json()) as Record<
string,
{
status?: { completed?: boolean }
outputs?: Record<string, { text?: string[] }>
}
>
const entry = hJson[pid]
if (entry?.status?.completed) {
const outs = entry.outputs ?? {}
const texts: string[] = []
for (const node of Object.values(outs)) {
if (Array.isArray(node.text)) texts.push(...node.text)
}
return {
stdout: stringIter(
(texts.length ? texts.join('\n') : '(no text returned)') +
'\n\n[saw: input/' +
stagedPath +
']\n'
),
exitCode: 0
}
}
}
await new Promise((r) => setTimeout(r, 1500))
}
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `see: timed out (prompt_id=${pid})`
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const hint = /Failed to fetch/i.test(msg)
? '\n hint: requires Comfy Cloud sign-in.'
: ''
return {
stdout: emptyIter(),
exitCode: 1,
stderr: msg + hint
}
}
}
export function registerSeeCommands(registry: CommandRegistry): void {
registry.register('see', see)
}

View File

@@ -0,0 +1,156 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/api', () => {
class FakeApi extends EventTarget {
listUserDataFullInfo = vi.fn()
getUserData = vi.fn()
storeUserData = vi.fn()
deleteUserData = vi.fn()
moveUserData = vi.fn()
fetchApi = vi.fn()
init = vi.fn()
}
return { api: new FakeApi() }
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ activeWorkflow: null })
}))
const openPanel = vi.fn()
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ openPanel })
}))
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { CommandRegistryImpl } from '../runtime'
import type { CmdContext } from '../types'
import { collect, emptyIter } from '../types'
import { MemoryVFS } from '../vfs/memory'
import { registerStateCommands } from './state'
function baseCtx(argv: string[]): CmdContext {
return {
argv,
stdin: emptyIter(),
env: new Map(),
cwd: '/',
vfs: new MemoryVFS(),
signal: new AbortController().signal
}
}
describe('state commands', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('missing-models reports 0 when none', async () => {
const r = new CommandRegistryImpl()
registerStateCommands(r)
const cmd = r.get('missing-models')!
const res = await cmd(baseCtx(['missing-models']))
expect(await collect(res.stdout)).toContain('0 missing')
})
it('missing-models lists candidates from the store', async () => {
const store = useMissingModelStore()
store.setMissingModels([
{
nodeId: 5,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'v1-5-pruned.safetensors',
directory: 'checkpoints',
isMissing: true
}
])
const r = new CommandRegistryImpl()
registerStateCommands(r)
const res = await r.get('missing-models')!(baseCtx(['missing-models']))
const out = await collect(res.stdout)
expect(out).toContain('MISSING')
expect(out).toContain('v1-5-pruned.safetensors')
expect(out).toContain('checkpoints')
expect(out).toContain('CheckpointLoaderSimple')
})
it('workflow-errors reports "no errors" when clean', async () => {
const r = new CommandRegistryImpl()
registerStateCommands(r)
const res = await r.get('workflow-errors')!(baseCtx(['workflow-errors']))
expect(await collect(res.stdout)).toContain('no errors')
})
it('workflow-errors counts missing models', async () => {
const store = useMissingModelStore()
store.setMissingModels([
{
nodeType: 'X',
widgetName: 'w',
isAssetSupported: false,
name: 'a',
isMissing: true
}
])
const r = new CommandRegistryImpl()
registerStateCommands(r)
const res = await r.get('workflow-errors')!(baseCtx(['workflow-errors']))
expect(await collect(res.stdout)).toContain('missing models: 1')
})
it('help emits command overview', async () => {
const r = new CommandRegistryImpl()
registerStateCommands(r)
const res = await r.get('help')!(baseCtx(['help']))
const out = await collect(res.stdout)
expect(out).toContain('coreutils')
expect(out).toContain('missing-models')
expect(out).toContain('Mounts')
})
it('show-errors opens right-side errors panel', async () => {
openPanel.mockClear()
const r = new CommandRegistryImpl()
registerStateCommands(r)
const res = await r.get('show-errors')!(baseCtx(['show-errors']))
expect(res.exitCode).toBe(0)
expect(openPanel).toHaveBeenCalledWith('errors')
})
it('show-missing-models does nothing when count is 0', async () => {
openPanel.mockClear()
const r = new CommandRegistryImpl()
registerStateCommands(r)
const res = await r.get('show-missing-models')!(
baseCtx(['show-missing-models'])
)
expect(res.exitCode).toBe(0)
expect(res.stderr).toContain('no missing')
expect(openPanel).not.toHaveBeenCalled()
})
it('show-missing-models opens panel when missing models exist', async () => {
openPanel.mockClear()
const store = useMissingModelStore()
store.setMissingModels([
{
nodeType: 'X',
widgetName: 'w',
isAssetSupported: false,
name: 'a',
isMissing: true
}
])
const r = new CommandRegistryImpl()
registerStateCommands(r)
const res = await r.get('show-missing-models')!(
baseCtx(['show-missing-models'])
)
expect(res.exitCode).toBe(0)
expect(openPanel).toHaveBeenCalledWith('errors')
})
})

View File

@@ -0,0 +1,117 @@
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* Read-only state commands that mirror what the user sees in the UI.
* Each command is backed by a Pinia store (not a raw API call), so the
* numbers stay consistent with banners, error panels, and badges.
*/
const missingModels: Command = async () => {
const store = useMissingModelStore()
const candidates = store.missingModelCandidates ?? []
if (candidates.length === 0) {
return { stdout: stringIter('0 missing models\n'), exitCode: 0 }
}
const lines = candidates.map((m) => {
const where = m.nodeId !== undefined ? `node #${m.nodeId}` : 'workflow'
const dir = m.directory ? ` (${m.directory})` : ''
const status =
m.isMissing === true
? 'MISSING'
: m.isMissing === false
? 'installed'
: 'pending'
return `${status}\t${m.nodeType}.${m.widgetName}\t${m.name}${dir}\t${where}`
})
return {
stdout: stringIter(lines.join('\n') + '\n'),
exitCode: 0
}
}
const workflowErrors: Command = async () => {
const errorStore = useExecutionErrorStore()
const missingStore = useMissingModelStore()
const lines: string[] = []
if (missingStore.missingModelCount > 0) {
lines.push(`missing models: ${missingStore.missingModelCount}`)
}
if (errorStore.hasAnyError) {
lines.push(`errors detected (see UI error overlay for detail)`)
}
if (lines.length === 0) {
return { stdout: stringIter('no errors\n'), exitCode: 0 }
}
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
const activeWorkflow: Command = async () => {
const store = useWorkflowStore()
const wf = store.activeWorkflow
if (!wf) {
return { stdout: stringIter('no active workflow\n'), exitCode: 0 }
}
const lines = [
`path: ${wf.path}`,
`modified: ${wf.isModified ? 'yes' : 'no'}`,
`persisted: ${wf.isPersisted ? 'yes' : 'no'}`
]
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
const help: Command = async () => {
const lines = [
'Available commands (this session):',
' coreutils: echo cat ls pwd wc head tail grep true false',
' comfy: cmd <id> invoke a registered UI command',
' cmd-list [regex] discover command ids',
' state: missing-models list missing models (same as UI banner)',
' workflow-errors summarize errors on the active workflow',
' active-workflow show the active workflow path + flags',
' show-errors open the right-side errors panel',
' show-missing-models open the errors panel and focus missing models',
' help this message',
'Mounts: /tmp (in-memory scratch), /workflows (saved workflows)'
]
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
const showErrorsPanel: Command = async () => {
const panel = useRightSidePanelStore()
panel.openPanel('errors')
const errorStore = useExecutionErrorStore()
errorStore.dismissErrorOverlay()
return { stdout: stringIter('opened right-side errors panel\n'), exitCode: 0 }
}
const showMissingModels: Command = async () => {
const missing = useMissingModelStore()
if (missing.missingModelCount === 0) {
return { stdout: emptyIter(), exitCode: 0, stderr: 'no missing models' }
}
const panel = useRightSidePanelStore()
panel.openPanel('errors')
const errorStore = useExecutionErrorStore()
errorStore.dismissErrorOverlay()
return {
stdout: stringIter(
`opened errors panel (${missing.missingModelCount} missing models)\n`
),
exitCode: 0
}
}
export function registerStateCommands(registry: CommandRegistry): void {
registry.register('missing-models', missingModels)
registry.register('workflow-errors', workflowErrors)
registry.register('active-workflow', activeWorkflow)
registry.register('show-errors', showErrorsPanel)
registry.register('show-missing-models', showMissingModels)
registry.register('help', help)
}

View File

@@ -0,0 +1,107 @@
import { api } from '@/scripts/api'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import type { Command, CmdContext, CommandRegistry } from '../types'
import { stringIter } from '../types'
interface LiteWidget {
name?: string
type?: string
value?: unknown
callback?: (v: unknown) => void
}
interface LiteNode {
id: number | string
widgets?: LiteWidget[]
}
function coerce(type: string | undefined, raw: string): unknown {
if (type === 'number' || type === 'INT' || type === 'FLOAT') {
const n = Number(raw)
if (Number.isFinite(n)) return n
}
return raw
}
async function pollUntilIdle(timeoutMs: number, signal: AbortSignal) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (signal.aborted) throw new Error('aborted')
const { Running, Pending } = await api.getQueue()
if (Running.length === 0 && Pending.length === 0) return
await new Promise((r) => setTimeout(r, 1200))
}
throw new Error('timed out waiting for queue')
}
async function* runSweep(ctx: CmdContext): AsyncIterable<string> {
const [, idArg, name, ...vals] = ctx.argv
if (!idArg || !name || vals.length === 0) {
yield 'usage: sweep <nodeId> <widgetName> <val1> [<val2> ...]\n'
return
}
const canvas = useCanvasStore().canvas
if (!canvas?.graph) {
yield 'error: no active graph\n'
return
}
const nodes = (canvas.graph as { _nodes: LiteNode[] })._nodes ?? []
const node = nodes.find((n) => String(n.id) === idArg)
if (!node) {
yield `error: no node ${idArg}\n`
return
}
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) {
yield `error: node ${idArg} has no widget "${name}"\n`
return
}
const cmdStore = useCommandStore()
const results: string[] = []
for (const raw of vals) {
if (ctx.signal.aborted) {
yield 'aborted\n'
return
}
const value = coerce(widget.type, raw)
widget.value = value
widget.callback?.(value)
yield `[${raw}] set ${name}=${JSON.stringify(value)} — queuing...\n`
await cmdStore.execute('Comfy.QueuePrompt')
yield `[${raw}] queued. waiting for idle...\n`
await pollUntilIdle(300_000, ctx.signal)
results.push(String(value))
yield `[${raw}] done.\n`
}
yield `sweep complete: ${name} over ${results.join(', ')}\n`
}
const sweepCmd: Command = async (ctx) => ({
stdout: runSweep(ctx),
exitCode: 0
})
const sweepHelpStr = `sweep <nodeId> <widgetName> <val1> [<val2> ...]
Sets the named widget on the given node to each value in turn,
queues a prompt after each set, and waits for the queue to drain
before moving to the next value.
Example — try CFG 5, 6, 7, 8 on node 3:
sweep 3 cfg 5 6 7 8
Combine with seq for ranges:
graph nodes KSampler | head -1 | ...
(seq output is line-based; use set-widget for single values)
`
export function registerSweepCommands(registry: CommandRegistry): void {
registry.register('sweep', sweepCmd)
registry.register('sweep-help', async () => ({
stdout: stringIter(sweepHelpStr),
exitCode: 0
}))
}

View File

@@ -0,0 +1,177 @@
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface TemplateInfoSlim {
name?: string
title?: string
localizedTitle?: string
description?: string
sourceModule?: string
}
interface TemplateModuleSlim {
moduleName: string
templates: TemplateInfoSlim[]
}
async function fetchTemplateJson(
id: string,
sourceModule: string
): Promise<unknown> {
if (sourceModule === 'default') {
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
}
return fetch(
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
).then((r) => r.json())
}
/**
* templates [filter]
*
* List available workflow templates. Output columns: moduleName/id — title.
* Optional regex/substring filter (case-insensitive) matches title, id, or
* description. Use before `load-template` to find a starting workflow.
*/
const templatesList: Command = async (ctx) => {
const filter = ctx.argv.slice(1).join(' ').trim()
let regex: RegExp | null = null
if (filter) {
try {
regex = new RegExp(filter, 'i')
} catch {
const escaped = filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
regex = new RegExp(escaped, 'i')
}
}
const store = useWorkflowTemplatesStore()
try {
if (!store.isLoaded) await store.loadWorkflowTemplates()
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr:
'templates: failed to load index — ' +
(err instanceof Error ? err.message : String(err))
}
}
const groups = store.groupedTemplates as Array<{
label: string
modules: TemplateModuleSlim[]
}>
const lines: string[] = []
let total = 0
for (const group of groups) {
for (const mod of group.modules) {
for (const tpl of mod.templates) {
const id = tpl.name ?? ''
const title = tpl.localizedTitle ?? tpl.title ?? id
const desc = (tpl.description ?? '').replace(/\s+/g, ' ').slice(0, 80)
if (
regex &&
!regex.test(id) &&
!regex.test(title) &&
!regex.test(desc)
) {
continue
}
lines.push(`${mod.moduleName}/${id}${title}`)
total++
}
}
}
if (total === 0) {
return {
stdout: stringIter(
filter
? `(no templates match "${filter}")\n`
: '(no templates loaded)\n'
),
exitCode: 0
}
}
lines.push('', `${total} template(s). Use: load-template <moduleName> <id>`)
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
/**
* load-template <moduleName> <id>
*
* Load a workflow template by module + id (as shown by `templates`).
* Replaces the active workflow. Use when the user asks for something
* starting from a standard pipeline instead of building from scratch.
*/
const loadTemplate: Command = async (ctx) => {
const [, moduleName, id] = ctx.argv
if (!moduleName || !id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: load-template <moduleName> <id> (run `templates` first)'
}
}
const store = useWorkflowTemplatesStore()
try {
if (!store.isLoaded) await store.loadWorkflowTemplates()
} catch {
/* keep going with whatever sourceModule was passed */
}
// Resolve the real sourceModule: when listings show moduleName='all',
// the template carries its own sourceModule. Also handles the common
// case of a template id that only lives under one known sourceModule.
let resolvedSource = moduleName
const groups = store.groupedTemplates as Array<{
modules: TemplateModuleSlim[]
}>
outer: for (const g of groups) {
for (const mod of g.modules) {
if (mod.moduleName !== moduleName && moduleName !== 'all') continue
for (const tpl of mod.templates) {
if (tpl.name === id) {
resolvedSource = tpl.sourceModule ?? mod.moduleName
break outer
}
}
}
}
try {
const json = (await fetchTemplateJson(id, resolvedSource)) as Parameters<
typeof app.loadGraphData
>[0]
await app.loadGraphData(json, true, true, id, {
openSource: 'template'
})
return {
stdout: stringIter(
`loaded template ${resolvedSource}/${id}` +
(resolvedSource !== moduleName
? ` (resolved from ${moduleName})`
: '') +
'\n'
),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr:
`load-template: failed to load ${resolvedSource}/${id}` +
(err instanceof Error ? err.message : String(err))
}
}
}
export function registerTemplateCommands(registry: CommandRegistry): void {
registry.register('templates', templatesList)
registry.register('load-template', loadTemplate)
}

View File

@@ -0,0 +1,172 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* validate <filename> [<prompt-text...>]
*
* Send an image (from ComfyUI's output/ folder) through the cloud
* GeminiNode (gemini-3-1-pro) to get a visual quality assessment. Use
* after any SaveImage to confirm the result matches user intent before
* moving on to expensive next-phase work (e.g. image-to-3D).
*
* If no prompt is given, asks Gemini for a concise 1-5 rating and
* description. Requires Comfy Cloud auth (same as other api_* nodes).
*/
const validate: Command = async (ctx) => {
const args = ctx.argv.slice(1)
if (args.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'usage: validate <filename_in_output> [<question...>]\n' +
' hint: `latest-output-name` gives the most recent filename'
}
}
const filename = args[0]
const question =
args.slice(1).join(' ').trim() ||
'Describe this image in one short sentence. Then rate its overall quality from 1-5. Format: "<description> | rating: N/5"'
// Minimal workflow: LoadImage (from output/) → GeminiNode → (implicit
// return in /history). We use type=output because SaveImage writes there.
// LoadImage reads from input/, so copy via the existing /upload/image
// path first — keeps this command side-effect-free on input/ by using
// subfolder='validate-staging'.
try {
const viewRes = await fetch(
api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=output`)
)
if (!viewRes.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `validate: cannot read output/${filename} (${viewRes.status})`
}
}
const blob = await viewRes.blob()
const form = new FormData()
form.append('image', blob, filename)
form.append('subfolder', 'agent-validate')
form.append('overwrite', 'true')
const up = await fetch(api.apiURL('/upload/image'), {
method: 'POST',
body: form
})
if (!up.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `validate: upload-to-input failed (${up.status})`
}
}
const upJson = (await up.json()) as { name?: string; subfolder?: string }
const stagedName = upJson.subfolder
? `${upJson.subfolder}/${upJson.name}`
: (upJson.name ?? filename)
const prompt = {
prompt: {
'1': {
class_type: 'LoadImage',
inputs: { image: stagedName }
},
'2': {
class_type: 'GeminiNode',
inputs: {
prompt: question,
model: 'gemini-3-1-pro',
seed: 1,
images: ['1', 0]
}
},
// PreviewAny is an OUTPUT_NODE — without it ComfyUI's executor
// culls the Gemini call as a dead branch (no consumer of its
// STRING output) and returns success without invoking the API.
'3': {
class_type: 'PreviewAny',
inputs: { source: ['2', 0] }
}
},
client_id: 'sno-agent-validate'
}
const queueRes = await fetch(api.apiURL('/prompt'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prompt)
})
if (!queueRes.ok) {
const text = await queueRes.text().catch(() => '')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `validate: queue rejected (${queueRes.status}) ${text.slice(0, 300)}`
}
}
const queued = (await queueRes.json()) as { prompt_id?: string }
const pid = queued.prompt_id
if (!pid) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'validate: queue did not return a prompt_id'
}
}
// Poll history for completion. Gemini API round-trips in seconds.
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const hRes = await fetch(api.apiURL(`/history/${pid}`))
if (hRes.ok) {
const hJson = (await hRes.json()) as Record<
string,
{
status?: { completed?: boolean }
outputs?: Record<string, { text?: string[] }>
}
>
const entry = hJson[pid]
if (entry?.status?.completed) {
const outputs = entry.outputs ?? {}
const texts: string[] = []
for (const node of Object.values(outputs)) {
if (Array.isArray(node.text)) texts.push(...node.text)
}
if (texts.length === 0) {
return {
stdout: stringIter('(validate: no text output)\n'),
exitCode: 0
}
}
return {
stdout: stringIter(texts.join('\n') + '\n'),
exitCode: 0
}
}
}
await new Promise((r) => setTimeout(r, 1500))
}
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `validate: timed out waiting for Gemini (prompt_id=${pid})`
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const hint = /Failed to fetch/i.test(msg)
? '\n hint: requires Comfy Cloud sign-in (menu → Sign In).'
: ''
return {
stdout: emptyIter(),
exitCode: 1,
stderr: msg + hint
}
}
}
export function registerValidateCommands(registry: CommandRegistry): void {
registry.register('validate', validate)
}

View File

@@ -0,0 +1,288 @@
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
function stripQuotes(s: string): string {
return s.trim().replace(/^['"`]|['"`]$/g, '')
}
/**
* save-as <name>
*
* Non-interactive "Save Workflow As". The core Comfy.SaveWorkflowAs command
* opens a modal prompt for the filename, which blocks the agent's
* tool-call flow. This wrapper calls workflowService.saveWorkflowAs with
* a pre-supplied filename so the LLM can save in one step.
*/
const saveAs: Command = async (ctx) => {
const name = stripQuotes(ctx.argv.slice(1).join(' '))
if (!name) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: save-as <filename>'
}
}
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow | null
if (!workflow) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'save-as: no active workflow'
}
}
try {
const ok = await useWorkflowService().saveWorkflowAs(workflow, {
filename: name
})
if (!ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'save-as: cancelled or failed'
}
}
return { stdout: stringIter(`saved as ${name}\n`), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* new-workflow [name]
*
* Create a new blank workflow. If a name is given, immediately persist it
* via save-as so the file is visible in /workflows without a modal.
*/
const newWorkflow: Command = async (ctx) => {
const name = stripQuotes(ctx.argv.slice(1).join(' '))
try {
await useCommandStore().execute('Comfy.NewBlankWorkflow')
if (!name) {
return { stdout: stringIter('new blank workflow\n'), exitCode: 0 }
}
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow | null
if (!workflow) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'new-workflow: no active workflow after create'
}
}
const ok = await useWorkflowService().saveWorkflowAs(workflow, {
filename: name
})
if (!ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'new-workflow: save-as cancelled or failed'
}
}
return {
stdout: stringIter(`new workflow saved as ${name}\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* rename-workflow <newname>
*
* Non-interactive rename of the active persisted workflow. Bypasses the
* modal prompt opened by Comfy.RenameWorkflow.
*/
const renameWorkflow: Command = async (ctx) => {
const newName = stripQuotes(ctx.argv.slice(1).join(' '))
if (!newName) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: rename-workflow <newname>'
}
}
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow | null
if (!workflow) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'rename-workflow: no active workflow'
}
}
if (!workflow.isPersisted) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'rename-workflow: workflow is not persisted — use save-as instead'
}
}
if (newName === workflow.filename) {
return {
stdout: stringIter(`rename-workflow: unchanged (${newName})\n`),
exitCode: 0
}
}
try {
const suffix = getWorkflowSuffix(workflow.suffix)
const newPath =
workflow.directory + '/' + ensureWorkflowSuffix(newName, suffix)
await useWorkflowService().renameWorkflow(workflow, newPath)
return {
stdout: stringIter(`renamed to ${newPath}\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* set-subgraph-desc <description...>
*
* Set the BlueprintDescription on the currently-open subgraph.
* Bypasses the modal prompt opened by Comfy.Subgraph.SetDescription.
*/
const setSubgraphDesc: Command = async (ctx) => {
const description = ctx.argv.slice(1).join(' ').trim()
if (!description) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: set-subgraph-desc <description...>'
}
}
const canvas = useCanvasStore().canvas
const subgraph = canvas?.subgraph
if (!subgraph) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'set-subgraph-desc: no active subgraph'
}
}
try {
const extra = (subgraph.extra ??= {}) as Record<string, unknown>
extra.BlueprintDescription = description.trim() || undefined
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
return {
stdout: stringIter(`subgraph description set\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* set-subgraph-aliases <alias1> [alias2 ...]
*
* Set the BlueprintSearchAliases on the currently-open subgraph.
* Bypasses the modal prompt opened by Comfy.Subgraph.SetSearchAliases.
*/
const setSubgraphAliases: Command = async (ctx) => {
const raw = ctx.argv.slice(1)
if (raw.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: set-subgraph-aliases <alias1> [alias2 ...]'
}
}
const aliases = raw
.flatMap((s) => s.split(','))
.map((s) => s.trim())
.filter(Boolean)
const canvas = useCanvasStore().canvas
const subgraph = canvas?.subgraph
if (!subgraph) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'set-subgraph-aliases: no active subgraph'
}
}
try {
const extra = (subgraph.extra ??= {}) as Record<string, unknown>
extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
return {
stdout: stringIter(`subgraph aliases: ${aliases.join(', ')}\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* clear-workflow --force
*
* Clear the active workflow without the native confirm() dialog.
* The --force flag is mandatory to prevent accidental destruction.
*/
const clearWorkflow: Command = async (ctx) => {
const force = ctx.argv.slice(1).includes('--force')
if (!force) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: clear-workflow --force (required to confirm destruction)'
}
}
try {
app.clean()
if (app.canvas.subgraph) {
const subgraph = app.canvas.subgraph
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
nonIoNodes.forEach((node) => subgraph.remove(node))
}
api.dispatchCustomEvent('graphCleared')
return { stdout: stringIter('workflow cleared\n'), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
export function registerWorkflowCommands(registry: CommandRegistry): void {
registry.register('save-as', saveAs)
registry.register('new-workflow', newWorkflow)
registry.register('rename-workflow', renameWorkflow)
registry.register('set-subgraph-desc', setSubgraphDesc)
registry.register('set-subgraph-aliases', setSubgraphAliases)
registry.register('clear-workflow', clearWorkflow)
}

View File

@@ -0,0 +1,176 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/api', () => ({
api: {
listUserDataFullInfo: vi.fn(),
getUserData: vi.fn(),
storeUserData: vi.fn(),
deleteUserData: vi.fn(),
moveUserData: vi.fn()
}
}))
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { registerComfyCommands } from './commands/comfy'
import { registerCoreutils } from './commands/coreutils'
import { CommandRegistryImpl, runScript } from './runtime'
import { collect } from './types'
import { MemoryVFS } from './vfs/memory'
import { MountedVFS } from './vfs/mount'
import { UserdataVFS } from './vfs/userdata'
function setupRegistry() {
const r = new CommandRegistryImpl()
registerCoreutils(r)
registerComfyCommands(r)
return r
}
function setupVfs() {
return new MountedVFS({
'/tmp': new MemoryVFS(),
'/workflows': new UserdataVFS('workflows')
})
}
function ctx(registry = setupRegistry(), vfs = setupVfs()) {
return {
registry,
vfs,
env: new Map<string, string>(),
cwd: '/',
signal: new AbortController().signal
}
}
describe('shell integration', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('lists mount roots at /', async () => {
const r = await runScript('ls /', ctx())
expect(r.exitCode).toBe(0)
const out = await collect(r.stdout)
expect(out).toContain('tmp')
expect(out).toContain('workflows')
})
it('ls /workflows routes through userdata API', async () => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue([
{ path: 'workflows/a.json', size: 10, modified: 1 },
{ path: 'workflows/b.json', size: 20, modified: 2 }
])
const r = await runScript('ls /workflows', ctx())
expect(r.exitCode).toBe(0)
expect(await collect(r.stdout)).toBe('a.json\nb.json\n')
expect(api.listUserDataFullInfo).toHaveBeenCalledWith('workflows')
})
it('cat /workflows/foo.json reads via userdata', async () => {
vi.mocked(api.getUserData).mockResolvedValue(
new Response('{"nodes":[]}', { status: 200 })
)
const r = await runScript('cat /workflows/foo.json', ctx())
expect(r.exitCode).toBe(0)
expect(await collect(r.stdout)).toBe('{"nodes":[]}')
expect(api.getUserData).toHaveBeenCalledWith('workflows/foo.json')
})
it('pipeline: ls | grep filters userdata listing', async () => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue([
{ path: 'workflows/cat.json', size: 1, modified: 1 },
{ path: 'workflows/dog.json', size: 1, modified: 1 }
])
const r = await runScript('ls /workflows | grep cat', ctx())
expect(await collect(r.stdout)).toBe('cat.json\n')
})
it('redirect > /tmp persists to memory mount', async () => {
const c = ctx()
await runScript('echo hello > /tmp/out.txt', c)
const r2 = await runScript('cat /tmp/out.txt', c)
expect(await collect(r2.stdout)).toBe('hello\n')
})
it('redirect > /workflows writes via userdata', async () => {
vi.mocked(api.storeUserData).mockResolvedValue(
new Response('', { status: 200 })
)
const r = await runScript('echo data > /workflows/new.json', ctx())
expect(r.exitCode).toBe(0)
expect(api.storeUserData).toHaveBeenCalledWith(
'workflows/new.json',
'data\n',
expect.anything()
)
})
it('&& short-circuits on ls failure', async () => {
vi.mocked(api.listUserDataFullInfo).mockRejectedValue(new Error('boom'))
const r = await runScript('ls /workflows && echo yes', ctx())
expect(r.exitCode).toBe(1)
expect(await collect(r.stdout)).not.toContain('yes')
})
it('cmd-list returns registered command ids', async () => {
const store = useCommandStore()
store.registerCommand({
id: 'Comfy.Test.Foo',
function: () => {},
label: 'Foo'
})
store.registerCommand({
id: 'Comfy.Test.Bar',
function: () => {},
label: 'Bar'
})
const r = await runScript('cmd-list Test', ctx())
const out = await collect(r.stdout)
expect(out).toContain('Comfy.Test.Foo')
expect(out).toContain('Comfy.Test.Bar')
})
it('cmd invokes a registered command', async () => {
const store = useCommandStore()
const spy = vi.fn()
store.registerCommand({
id: 'Comfy.Test.Click',
function: spy,
label: 'Click'
})
const r = await runScript('cmd Comfy.Test.Click', ctx())
expect(r.exitCode).toBe(0)
expect(spy).toHaveBeenCalledTimes(1)
})
it('cmd returns 127 for unknown command', async () => {
const r = await runScript('cmd Comfy.Nope', ctx())
expect(r.exitCode).toBe(127)
expect(r.stderr).toContain('unknown')
})
it('unknown mount path errors cleanly', async () => {
const r = await runScript('ls /nowhere', ctx())
expect(r.exitCode).toBe(1)
expect(r.stderr).toMatch(/no mount/)
})
it('empty /workflows listing returns no output', async () => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue([])
const r = await runScript('ls /workflows', ctx())
expect(r.exitCode).toBe(0)
expect(await collect(r.stdout)).toBe('')
})
it('write then read roundtrip on /tmp via shell', async () => {
const c = ctx()
await runScript('echo line1 > /tmp/a ; echo line2 >> /tmp/a', c)
const r = await runScript('cat /tmp/a | wc', c)
expect(await collect(r.stdout)).toBe('2 2 12\n')
})
})

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { parseScript } from './parser'
describe('parseScript', () => {
it('parses single command', () => {
expect(parseScript('echo hi')).toEqual({
type: 'simple',
cmd: { argv: ['echo', 'hi'], redirect: undefined }
})
})
it('parses quoted arguments', () => {
const node = parseScript('echo "hello world"')
expect(node).toMatchObject({
type: 'simple',
cmd: { argv: ['echo', 'hello world'] }
})
})
it('parses pipes', () => {
const node = parseScript('a | b | c')
expect(node.type).toBe('pipe')
if (node.type === 'pipe') {
expect(node.cmds.map((c) => c.argv[0])).toEqual(['a', 'b', 'c'])
}
})
it('parses seq ;', () => {
const node = parseScript('a ; b')
expect(node.type).toBe('seq')
})
it('parses && as and', () => {
const node = parseScript('a && b')
expect(node.type).toBe('and')
})
it('parses || as or', () => {
const node = parseScript('a || b')
expect(node.type).toBe('or')
})
it('precedence: pipe binds tightest, then and/or, then seq', () => {
const node = parseScript('a && b | c || d ; e')
expect(node.type).toBe('seq')
if (node.type !== 'seq') return
expect(node.right).toMatchObject({
type: 'simple',
cmd: { argv: ['e'] }
})
expect(node.left.type).toBe('or')
})
it('parses > redirect on simple cmd', () => {
const node = parseScript('echo hi > /tmp/x')
expect(node).toMatchObject({
type: 'simple',
cmd: { argv: ['echo', 'hi'], redirect: { op: '>', path: '/tmp/x' } }
})
})
it('parses >> redirect', () => {
const node = parseScript('echo hi >> /tmp/x')
if (node.type !== 'simple') throw new Error('expected simple')
expect(node.cmd.redirect).toEqual({ op: '>>', path: '/tmp/x' })
})
it('lifts pipe final redirect to pipe node', () => {
const node = parseScript('a | b > /tmp/x')
expect(node.type).toBe('pipe')
if (node.type !== 'pipe') return
expect(node.redirect).toEqual({ op: '>', path: '/tmp/x' })
expect(node.cmds[1].redirect).toBeUndefined()
})
it('expands $VAR from env', () => {
const node = parseScript('echo $FOO', { FOO: 'bar' })
expect(node).toMatchObject({
type: 'simple',
cmd: { argv: ['echo', 'bar'] }
})
})
it('throws on command substitution $(...)', () => {
expect(() => parseScript('echo $(ls)')).toThrow()
})
it('throws on glob', () => {
expect(() => parseScript('echo *.txt')).toThrow(/glob/)
})
it('throws on background &', () => {
expect(() => parseScript('sleep 1 &')).toThrow()
})
})

132
src/agent/shell/parser.ts Normal file
View File

@@ -0,0 +1,132 @@
import { parse as tokenize } from 'shell-quote'
import type { Cmd, Node, Redirect } from './types'
type Token =
| string
| { op: string; pattern?: string }
| { pattern: string }
| { comment: string }
const UNSUPPORTED_OPS = new Set([
'(',
')',
'&',
'<',
'<<',
'<<<',
'<(',
'>(',
'>&',
'<&'
])
export function parseScript(src: string, env?: Record<string, string>): Node {
const tokens = tokenize(src, env) as Token[]
if (tokens.length === 0) {
return { type: 'simple', cmd: { argv: [] } }
}
for (const t of tokens) {
if (typeof t === 'object') {
if ('pattern' in t && !('op' in t)) {
throw new Error(`glob not supported: ${t.pattern}`)
}
if ('comment' in t) continue
if ('op' in t) {
const op = t.op
if (op === 'glob') {
throw new Error(`glob not supported: ${t.pattern ?? ''}`)
}
if (UNSUPPORTED_OPS.has(op)) {
throw new Error(`unsupported operator: ${op}`)
}
}
}
}
return foldSeq(tokens)
}
function splitBy(tokens: Token[], ops: string[]): Token[][] {
const parts: Token[][] = [[]]
for (const t of tokens) {
if (typeof t === 'object' && 'op' in t && ops.includes(t.op)) {
parts.push([{ op: t.op } as Token], [])
} else {
parts[parts.length - 1].push(t)
}
}
return parts
}
function foldSeq(tokens: Token[]): Node {
const parts = splitBy(tokens, [';'])
const segs: Token[][] = []
for (let i = 0; i < parts.length; i += 2) segs.push(parts[i])
const filtered = segs.filter((s) => s.length > 0)
if (filtered.length === 0) return { type: 'simple', cmd: { argv: [] } }
let acc = foldLogical(filtered[0])
for (let i = 1; i < filtered.length; i++) {
acc = { type: 'seq', left: acc, right: foldLogical(filtered[i]) }
}
return acc
}
function foldLogical(tokens: Token[]): Node {
const parts: Array<{ op?: '&&' | '||'; toks: Token[] }> = [{ toks: [] }]
for (const t of tokens) {
if (
typeof t === 'object' &&
'op' in t &&
(t.op === '&&' || t.op === '||')
) {
parts.push({ op: t.op, toks: [] })
} else {
parts[parts.length - 1].toks.push(t)
}
}
let acc = foldPipe(parts[0].toks)
for (let i = 1; i < parts.length; i++) {
const right = foldPipe(parts[i].toks)
acc = { type: parts[i].op === '&&' ? 'and' : 'or', left: acc, right }
}
return acc
}
function foldPipe(tokens: Token[]): Node {
const parts = splitBy(tokens, ['|'])
const segs: Token[][] = []
for (let i = 0; i < parts.length; i += 2) segs.push(parts[i])
const cmds = segs.map(toCmd)
if (cmds.length === 1) {
return { type: 'simple', cmd: cmds[0] }
}
const last = cmds[cmds.length - 1]
const redirect = last.redirect
const pipeCmds = cmds.map((c, i) =>
i === cmds.length - 1 ? { ...c, redirect: undefined } : c
)
return { type: 'pipe', cmds: pipeCmds, redirect }
}
function toCmd(tokens: Token[]): Cmd {
const argv: string[] = []
let redirect: Redirect | undefined
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i]
if (typeof t === 'string') {
argv.push(t)
} else if (typeof t === 'object' && 'op' in t) {
if (t.op === '>' || t.op === '>>') {
const next = tokens[i + 1]
if (typeof next !== 'string') {
throw new Error(`redirect target missing after ${t.op}`)
}
redirect = { op: t.op, path: next }
i++
} else {
throw new Error(`unexpected operator in command: ${t.op}`)
}
}
}
return { argv, redirect }
}

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import { CommandRegistryImpl, runScript } from './runtime'
import type { ExecContext } from './runtime'
import { collect, emptyIter, lines, stringIter } from './types'
import type { Command } from './types'
import { MemoryVFS } from './vfs/memory'
function setup(): ExecContext & { registry: CommandRegistryImpl } {
const registry = new CommandRegistryImpl()
const echo: Command = async (ctx) => ({
stdout: stringIter(ctx.argv.slice(1).join(' ') + '\n'),
exitCode: 0
})
const cat: Command = async (ctx) => ({ stdout: ctx.stdin, exitCode: 0 })
const grep: Command = async (ctx) => {
const re = new RegExp(ctx.argv[1])
async function* gen(): AsyncIterable<string> {
for await (const l of lines(ctx.stdin)) {
if (re.test(l)) yield l + '\n'
}
}
return { stdout: gen(), exitCode: 0 }
}
const fail: Command = async () => ({ stdout: emptyIter(), exitCode: 2 })
const count: Command = async (ctx) => {
let n = 0
for await (const _l of lines(ctx.stdin)) n++
return { stdout: stringIter(String(n) + '\n'), exitCode: 0 }
}
const boom: Command = async () => {
throw new Error('kaboom')
}
registry.register('echo', echo)
registry.register('cat', cat)
registry.register('grep', grep)
registry.register('fail', fail)
registry.register('count', count)
registry.register('boom', boom)
return {
registry,
vfs: new MemoryVFS(),
env: new Map(),
cwd: '/'
}
}
describe('runScript', () => {
it('runs simple command', async () => {
const ctx = setup()
const r = await runScript('echo hi', ctx)
expect(r.exitCode).toBe(0)
expect(await collect(r.stdout)).toBe('hi\n')
})
it('pipes through stages', async () => {
const ctx = setup()
const r = await runScript('echo a | cat | cat', ctx)
expect(await collect(r.stdout)).toBe('a\n')
})
it('grep filters piped input', async () => {
const ctx = setup()
const r = await runScript('echo foo | grep oo', ctx)
expect(await collect(r.stdout)).toBe('foo\n')
const r2 = await runScript('echo bar | grep oo', ctx)
expect(await collect(r2.stdout)).toBe('')
})
it('&& short-circuits on failure', async () => {
const ctx = setup()
const r = await runScript('fail && echo nope', ctx)
expect(r.exitCode).toBe(2)
expect(await collect(r.stdout)).toBe('')
})
it('&& runs right on success', async () => {
const ctx = setup()
const r = await runScript('echo a && echo b', ctx)
expect(r.exitCode).toBe(0)
expect(await collect(r.stdout)).toBe('a\nb\n')
})
it('|| runs right on failure', async () => {
const ctx = setup()
const r = await runScript('fail || echo recover', ctx)
expect(r.exitCode).toBe(0)
expect(await collect(r.stdout)).toContain('recover')
})
it('redirect > writes stdout to vfs', async () => {
const ctx = setup()
const r = await runScript('echo hello > /out.txt', ctx)
expect(r.exitCode).toBe(0)
expect(await collect(r.stdout)).toBe('')
expect(await ctx.vfs.read('/out.txt')).toBe('hello\n')
})
it('redirect >> appends', async () => {
const ctx = setup()
await runScript('echo a >> /log', ctx)
await runScript('echo b >> /log', ctx)
expect(await ctx.vfs.read('/log')).toBe('a\nb\n')
})
it('pipe redirect writes final stage output', async () => {
const ctx = setup()
await runScript('echo foo | cat > /p.txt', ctx)
expect(await ctx.vfs.read('/p.txt')).toBe('foo\n')
})
it('unknown command returns 127', async () => {
const ctx = setup()
const r = await runScript('notreal', ctx)
expect(r.exitCode).toBe(127)
expect(r.stderr).toContain('not found')
})
it('throwing command returns 1', async () => {
const ctx = setup()
const r = await runScript('boom', ctx)
expect(r.exitCode).toBe(1)
expect(r.stderr).toContain('kaboom')
})
it('pre-aborted signal returns 130', async () => {
const ctx = setup()
const ac = new AbortController()
ac.abort()
const r = await runScript('echo hi', { ...ctx, signal: ac.signal })
expect(r.exitCode).toBe(130)
})
it('seq runs both sides', async () => {
const ctx = setup()
const r = await runScript('echo a ; echo b', ctx)
expect(await collect(r.stdout)).toBe('a\nb\n')
})
it('count consumes piped lines', async () => {
const ctx = setup()
const r = await runScript('echo a | count', ctx)
expect(await collect(r.stdout)).toBe('1\n')
})
it('parse error returns exit 2', async () => {
const ctx = setup()
const r = await runScript('echo $(ls)', ctx)
expect(r.exitCode).toBe(2)
})
})

214
src/agent/shell/runtime.ts Normal file
View File

@@ -0,0 +1,214 @@
import type {
Cmd,
CmdContext,
CmdResult,
Command,
CommandRegistry,
Node,
Redirect,
VFS
} from './types'
import { collect, emptyIter } from './types'
import { parseScript } from './parser'
type Resolver = (name: string) => Command | undefined
export class CommandRegistryImpl implements CommandRegistry {
private map = new Map<string, Command>()
private resolvers: Resolver[] = []
get(name: string): Command | undefined {
const direct = this.map.get(name)
if (direct) return direct
for (const r of this.resolvers) {
const hit = r(name)
if (hit) return hit
}
return undefined
}
register(name: string, cmd: Command): void {
this.map.set(name, cmd)
}
/**
* Add a lookup fallback used when a name isn't in the main registry.
* Resolvers are tried in registration order until one returns a handler.
*/
addResolver(resolver: Resolver): void {
this.resolvers.push(resolver)
}
list(): string[] {
return [...this.map.keys()].sort()
}
}
export interface ExecContext {
registry: CommandRegistry
vfs: VFS
env: Map<string, string>
cwd: string
signal?: AbortSignal
stdin?: AsyncIterable<string>
}
function makeCtx(
ctx: ExecContext,
argv: string[],
stdin: AsyncIterable<string>
): CmdContext {
return {
argv,
stdin,
env: ctx.env,
cwd: ctx.cwd,
vfs: ctx.vfs,
signal: ctx.signal ?? new AbortController().signal
}
}
async function applyRedirect(
res: CmdResult,
redirect: Redirect,
vfs: VFS
): Promise<CmdResult> {
const data = await collect(res.stdout)
if (redirect.op === '>') await vfs.write(redirect.path, data)
else await vfs.append(redirect.path, data)
return { stdout: emptyIter(), exitCode: res.exitCode, stderr: res.stderr }
}
async function runSimple(
cmd: Cmd,
ctx: ExecContext,
stdin: AsyncIterable<string>
): Promise<CmdResult> {
if (ctx.signal?.aborted) {
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
}
const name = cmd.argv[0]
const handler = ctx.registry.get(name)
if (!handler) {
return {
stdout: emptyIter(),
exitCode: 127,
stderr: `${name}: command not found`
}
}
let res: CmdResult
try {
res = await handler(makeCtx(ctx, cmd.argv, stdin))
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
if (cmd.redirect) res = await applyRedirect(res, cmd.redirect, ctx.vfs)
return res
}
async function runPipe(
cmds: Cmd[],
ctx: ExecContext,
stdin: AsyncIterable<string>,
redirect: Redirect | undefined
): Promise<CmdResult> {
let cur = stdin
let exit = 0
let stderr: string | undefined
for (let i = 0; i < cmds.length; i++) {
const last = i === cmds.length - 1
const cmd = cmds[i]
const inner = last ? cmd : { ...cmd, redirect: undefined }
const res = await runSimple(inner, ctx, cur)
cur = res.stdout
exit = res.exitCode
if (res.stderr) stderr = res.stderr
if (ctx.signal?.aborted) {
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
}
}
let result: CmdResult = { stdout: cur, exitCode: exit, stderr }
if (redirect) result = await applyRedirect(result, redirect, ctx.vfs)
return result
}
async function runNode(node: Node, ctx: ExecContext): Promise<CmdResult> {
const stdin = ctx.stdin ?? emptyIter()
if (ctx.signal?.aborted) {
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
}
if (node.type === 'simple') return runSimple(node.cmd, ctx, stdin)
if (node.type === 'pipe') return runPipe(node.cmds, ctx, stdin, node.redirect)
const left = await runNode(node.left, ctx)
const leftOut = await collect(left.stdout)
if (node.type === 'and' && left.exitCode !== 0) {
return {
stdout: toIter(leftOut),
exitCode: left.exitCode,
stderr: left.stderr
}
}
if (node.type === 'or' && left.exitCode === 0) {
return { stdout: toIter(leftOut), exitCode: 0, stderr: left.stderr }
}
const right = await runNode(node.right, ctx)
const rightOut = await collect(right.stdout)
const combined = leftOut + rightOut
return {
stdout: toIter(combined),
exitCode: right.exitCode,
stderr: right.stderr ?? left.stderr
}
}
async function* toIter(s: string): AsyncIterable<string> {
if (s.length > 0) yield s
}
/**
* Commands whose argument list is taken literally (unparsed), so embedded
* quotes, newlines, semicolons, and pipes pass through to the command.
* This lets the user (or LLM) write raw JS with no shell escaping.
*/
const RAW_ARG_COMMANDS = ['run-js', 'describe']
/**
* If the input matches `<cmd> <rest>` where <cmd> is a raw-arg command,
* bypass shell-quote and build a single simple node by hand. This avoids
* escaping hell for run-js and describe.
*/
function tryRawArgShortcut(src: string): Node | null {
const trimmed = src.replace(/^\s+/, '')
for (const c of RAW_ARG_COMMANDS) {
if (trimmed.startsWith(c + ' ') || trimmed === c) {
const rest = trimmed.slice(c.length).replace(/^\s+/, '')
if (!rest) return null // let normal parser handle usage
return { type: 'simple', cmd: { argv: [c, rest], redirect: undefined } }
}
}
return null
}
export async function runScript(
src: string,
ctx: ExecContext
): Promise<CmdResult> {
const shortcut = tryRawArgShortcut(src)
if (shortcut) return runNode(shortcut, ctx)
let node: Node
try {
node = parseScript(src, Object.fromEntries(ctx.env))
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: err instanceof Error ? err.message : String(err)
}
}
return runNode(node, ctx)
}

84
src/agent/shell/types.ts Normal file
View File

@@ -0,0 +1,84 @@
export interface Redirect {
op: '>' | '>>'
path: string
}
export interface Cmd {
argv: string[]
redirect?: Redirect
}
export type Node =
| { type: 'pipe'; cmds: Cmd[]; redirect?: Redirect }
| { type: 'and' | 'or' | 'seq'; left: Node; right: Node }
| { type: 'simple'; cmd: Cmd }
export interface VFS {
list(path: string): Promise<VfsEntry[]>
read(path: string): Promise<string>
write(path: string, data: string): Promise<void>
append(path: string, data: string): Promise<void>
delete(path: string): Promise<void>
move(src: string, dest: string): Promise<void>
exists(path: string): Promise<boolean>
}
export interface VfsEntry {
name: string
path: string
type: 'file' | 'dir'
size?: number
modified?: number
}
export interface CmdContext {
argv: string[]
stdin: AsyncIterable<string>
env: Map<string, string>
cwd: string
vfs: VFS
signal: AbortSignal
}
export interface CmdResult {
stdout: AsyncIterable<string>
exitCode: number
stderr?: string
}
export type Command = (ctx: CmdContext) => Promise<CmdResult>
export interface CommandRegistry {
get(name: string): Command | undefined
register(name: string, cmd: Command): void
list(): string[]
}
export async function* emptyIter(): AsyncIterable<string> {
// no-op
}
export async function* stringIter(s: string): AsyncIterable<string> {
if (s.length > 0) yield s
}
export async function collect(iter: AsyncIterable<string>): Promise<string> {
const parts: string[] = []
for await (const chunk of iter) parts.push(chunk)
return parts.join('')
}
export async function* lines(
iter: AsyncIterable<string>
): AsyncIterable<string> {
let buf = ''
for await (const chunk of iter) {
buf += chunk
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
yield buf.slice(0, nl)
buf = buf.slice(nl + 1)
}
}
if (buf.length > 0) yield buf
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { MemoryVFS } from './memory'
describe('MemoryVFS', () => {
it('write + read roundtrip', async () => {
const fs = new MemoryVFS()
await fs.write('/a.txt', 'hello')
expect(await fs.read('/a.txt')).toBe('hello')
})
it('list direct children', async () => {
const fs = new MemoryVFS()
await fs.write('/dir/a.txt', '1')
await fs.write('/dir/b.txt', '2')
await fs.write('/dir/sub/c.txt', '3')
const entries = await fs.list('/dir')
expect(entries.map((e) => e.name)).toEqual(['a.txt', 'b.txt', 'sub'])
expect(entries.find((e) => e.name === 'sub')?.type).toBe('dir')
expect(entries.find((e) => e.name === 'a.txt')?.type).toBe('file')
})
it('list root', async () => {
const fs = new MemoryVFS()
await fs.write('/foo.txt', 'x')
const entries = await fs.list('/')
expect(entries.map((e) => e.name)).toEqual(['foo.txt'])
})
it('append', async () => {
const fs = new MemoryVFS()
await fs.append('/log', 'a\n')
await fs.append('/log', 'b\n')
expect(await fs.read('/log')).toBe('a\nb\n')
})
it('move', async () => {
const fs = new MemoryVFS()
await fs.write('/from', 'data')
await fs.move('/from', '/to')
expect(await fs.exists('/from')).toBe(false)
expect(await fs.read('/to')).toBe('data')
})
it('delete', async () => {
const fs = new MemoryVFS()
await fs.write('/a', 'x')
await fs.delete('/a')
expect(await fs.exists('/a')).toBe(false)
})
it('normalizes . and ..', async () => {
const fs = new MemoryVFS()
await fs.write('/a/b/../c.txt', 'v')
expect(await fs.read('/a/c.txt')).toBe('v')
})
it('throws on missing file', async () => {
const fs = new MemoryVFS()
await expect(fs.read('/nope')).rejects.toThrow(/no such/)
})
it('throws listing nonexistent dir', async () => {
const fs = new MemoryVFS()
await expect(fs.list('/nope')).rejects.toThrow(/no such/)
})
it('exists returns true for dir prefixes', async () => {
const fs = new MemoryVFS()
await fs.write('/dir/a', '1')
expect(await fs.exists('/dir')).toBe(true)
})
})

View File

@@ -0,0 +1,86 @@
import type { VFS, VfsEntry } from '../types'
function normalize(path: string): string {
if (!path.startsWith('/')) path = '/' + path
const parts = path.split('/').filter((p) => p.length > 0)
const stack: string[] = []
for (const p of parts) {
if (p === '.') continue
if (p === '..') stack.pop()
else stack.push(p)
}
return '/' + stack.join('/')
}
export class MemoryVFS implements VFS {
private files = new Map<string, string>()
async list(path: string): Promise<VfsEntry[]> {
const dir = normalize(path)
const entries = new Map<string, VfsEntry>()
let found = dir === '/'
for (const key of this.files.keys()) {
if (!key.startsWith(dir === '/' ? '/' : dir + '/') && key !== dir)
continue
if (key === dir) continue
const rest = key.slice(dir === '/' ? 1 : dir.length + 1)
const slash = rest.indexOf('/')
if (slash === -1) {
entries.set(rest, {
name: rest,
path: key,
type: 'file',
size: this.files.get(key)!.length
})
} else {
const name = rest.slice(0, slash)
entries.set(name, { name, path: dir + '/' + name, type: 'dir' })
}
found = true
}
if (!found && dir !== '/') {
throw new Error(`no such file or directory: ${dir}`)
}
return [...entries.values()].sort((a, b) => a.name.localeCompare(b.name))
}
async read(path: string): Promise<string> {
const p = normalize(path)
const data = this.files.get(p)
if (data === undefined) throw new Error(`no such file or directory: ${p}`)
return data
}
async write(path: string, data: string): Promise<void> {
this.files.set(normalize(path), data)
}
async append(path: string, data: string): Promise<void> {
const p = normalize(path)
this.files.set(p, (this.files.get(p) ?? '') + data)
}
async delete(path: string): Promise<void> {
const p = normalize(path)
if (!this.files.delete(p)) {
throw new Error(`no such file or directory: ${p}`)
}
}
async move(src: string, dest: string): Promise<void> {
const s = normalize(src)
const d = normalize(dest)
const data = this.files.get(s)
if (data === undefined) throw new Error(`no such file or directory: ${s}`)
this.files.delete(s)
this.files.set(d, data)
}
async exists(path: string): Promise<boolean> {
const p = normalize(path)
if (this.files.has(p)) return true
const prefix = p === '/' ? '/' : p + '/'
for (const k of this.files.keys()) if (k.startsWith(prefix)) return true
return false
}
}

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { MemoryVFS } from './memory'
import { MountedVFS } from './mount'
function setup() {
const tmp = new MemoryVFS()
const wf = new MemoryVFS()
const fs = new MountedVFS({
'/tmp': tmp,
'/workflows': wf
})
return { fs, tmp, wf }
}
describe('MountedVFS', () => {
it('list / shows mount roots', async () => {
const { fs } = setup()
const entries = await fs.list('/')
expect(entries.map((e) => e.name).sort()).toEqual(['tmp', 'workflows'])
expect(entries.every((e) => e.type === 'dir')).toBe(true)
})
it('dispatches read to correct mount', async () => {
const { fs, tmp } = setup()
await tmp.write('/a.txt', 'hello')
expect(await fs.read('/tmp/a.txt')).toBe('hello')
})
it('write routes to mount and list reflects prefix', async () => {
const { fs } = setup()
await fs.write('/workflows/foo.json', '{}')
const entries = await fs.list('/workflows')
expect(entries.map((e) => e.name)).toEqual(['foo.json'])
expect(entries[0].path).toBe('/workflows/foo.json')
})
it('move within same mount', async () => {
const { fs } = setup()
await fs.write('/tmp/a', 'x')
await fs.move('/tmp/a', '/tmp/b')
expect(await fs.exists('/tmp/a')).toBe(false)
expect(await fs.read('/tmp/b')).toBe('x')
})
it('move across mounts copies + deletes', async () => {
const { fs } = setup()
await fs.write('/tmp/a', 'x')
await fs.move('/tmp/a', '/workflows/a')
expect(await fs.exists('/tmp/a')).toBe(false)
expect(await fs.read('/workflows/a')).toBe('x')
})
it('throws on unmounted path', async () => {
const { fs } = setup()
await expect(fs.read('/unknown/x')).rejects.toThrow(/no mount/)
})
it('exists returns false for unmounted', async () => {
const { fs } = setup()
expect(await fs.exists('/unknown/x')).toBe(false)
})
it('normalizes .. in paths', async () => {
const { fs } = setup()
await fs.write('/tmp/a', 'x')
expect(await fs.read('/tmp/sub/../a')).toBe('x')
})
})

View File

@@ -0,0 +1,124 @@
import type { VFS, VfsEntry } from '../types'
interface Mount {
prefix: string
fs: VFS
}
function normalize(path: string): string {
if (!path.startsWith('/')) path = '/' + path
const parts = path.split('/').filter((p) => p.length > 0)
const stack: string[] = []
for (const p of parts) {
if (p === '.') continue
if (p === '..') stack.pop()
else stack.push(p)
}
return '/' + stack.join('/')
}
export class MountedVFS implements VFS {
private mounts: Mount[]
constructor(mounts: Record<string, VFS>) {
this.mounts = Object.entries(mounts)
.map(([prefix, fs]) => ({
prefix: prefix === '/' ? '' : prefix.replace(/\/$/, ''),
fs
}))
.sort((a, b) => b.prefix.length - a.prefix.length)
}
private resolve(path: string): { mount: Mount; relative: string } {
const abs = normalize(path)
for (const mount of this.mounts) {
if (mount.prefix === '') {
return { mount, relative: abs }
}
if (abs === mount.prefix) {
return { mount, relative: '/' }
}
if (abs.startsWith(mount.prefix + '/')) {
return { mount, relative: abs.slice(mount.prefix.length) || '/' }
}
}
throw new Error(`no mount for path: ${abs}`)
}
private decorate(mount: Mount, entries: VfsEntry[]): VfsEntry[] {
if (mount.prefix === '') return entries
return entries.map((e) => ({
...e,
path: mount.prefix + (e.path.startsWith('/') ? e.path : '/' + e.path)
}))
}
async list(path: string): Promise<VfsEntry[]> {
const abs = normalize(path)
if (abs === '/') {
const topMounts = this.mounts
.filter((m) => m.prefix !== '')
.map((m) => m.prefix)
const roots = new Set<string>()
for (const p of topMounts) {
const name = p.split('/').filter(Boolean)[0]
if (name) roots.add(name)
}
const hasRoot = this.mounts.some((m) => m.prefix === '')
if (hasRoot) {
const { mount } = this.resolve('/')
const rootEntries = await mount.fs.list('/')
for (const e of rootEntries) roots.add(e.name.replace(/\/$/, ''))
}
return [...roots].sort().map((name) => ({
name,
path: '/' + name,
type: 'dir'
}))
}
const { mount, relative } = this.resolve(abs)
const entries = await mount.fs.list(relative)
return this.decorate(mount, entries)
}
async read(path: string): Promise<string> {
const { mount, relative } = this.resolve(path)
return mount.fs.read(relative)
}
async write(path: string, data: string): Promise<void> {
const { mount, relative } = this.resolve(path)
return mount.fs.write(relative, data)
}
async append(path: string, data: string): Promise<void> {
const { mount, relative } = this.resolve(path)
return mount.fs.append(relative, data)
}
async delete(path: string): Promise<void> {
const { mount, relative } = this.resolve(path)
return mount.fs.delete(relative)
}
async move(src: string, dest: string): Promise<void> {
const s = this.resolve(src)
const d = this.resolve(dest)
if (s.mount !== d.mount) {
const data = await s.mount.fs.read(s.relative)
await d.mount.fs.write(d.relative, data)
await s.mount.fs.delete(s.relative)
return
}
return s.mount.fs.move(s.relative, d.relative)
}
async exists(path: string): Promise<boolean> {
try {
const { mount, relative } = this.resolve(path)
return mount.fs.exists(relative)
} catch {
return false
}
}
}

View File

@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/api', () => ({
api: {
listUserDataFullInfo: vi.fn(),
getUserData: vi.fn(),
storeUserData: vi.fn(),
deleteUserData: vi.fn(),
moveUserData: vi.fn()
}
}))
import { api } from '@/scripts/api'
import { UserdataVFS } from './userdata'
const mocked = vi.mocked(api)
function respOk(body = ''): Response {
return new Response(body, { status: 200 })
}
describe('UserdataVFS', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('list returns files under the root', async () => {
mocked.listUserDataFullInfo.mockResolvedValue([
{ path: 'workflows/a.json', size: 10, modified: 1 },
{ path: 'workflows/b.json', size: 20, modified: 2 }
])
const fs = new UserdataVFS('workflows')
const entries = await fs.list('/')
expect(mocked.listUserDataFullInfo).toHaveBeenCalledWith('workflows')
expect(entries.map((e) => e.name)).toEqual(['a.json', 'b.json'])
expect(entries[0].type).toBe('file')
})
it('list infers subdirs', async () => {
mocked.listUserDataFullInfo.mockResolvedValue([
{ path: 'workflows/a.json', size: 10, modified: 1 },
{ path: 'workflows/sub/b.json', size: 20, modified: 2 }
])
const fs = new UserdataVFS('workflows')
const entries = await fs.list('/')
expect(entries.map((e) => e.name).sort()).toEqual(['a.json', 'sub'])
expect(entries.find((e) => e.name === 'sub')?.type).toBe('dir')
})
it('read returns body text', async () => {
mocked.getUserData.mockResolvedValue(respOk('hello'))
const fs = new UserdataVFS('workflows')
expect(await fs.read('/a.json')).toBe('hello')
expect(mocked.getUserData).toHaveBeenCalledWith('workflows/a.json')
})
it('write POSTs via storeUserData', async () => {
mocked.storeUserData.mockResolvedValue(respOk())
const fs = new UserdataVFS('workflows')
await fs.write('/a.json', '{}')
expect(mocked.storeUserData).toHaveBeenCalledWith(
'workflows/a.json',
'{}',
expect.objectContaining({ stringify: false })
)
})
it('delete calls deleteUserData', async () => {
mocked.deleteUserData.mockResolvedValue(respOk())
const fs = new UserdataVFS('workflows')
await fs.delete('/a.json')
expect(mocked.deleteUserData).toHaveBeenCalledWith('workflows/a.json')
})
it('move calls moveUserData', async () => {
mocked.moveUserData.mockResolvedValue(respOk())
const fs = new UserdataVFS('workflows')
await fs.move('/a.json', '/b.json')
expect(mocked.moveUserData).toHaveBeenCalledWith(
'workflows/a.json',
'workflows/b.json',
{ overwrite: false }
)
})
it('read throws on non-ok', async () => {
mocked.getUserData.mockResolvedValue(new Response('no', { status: 404 }))
const fs = new UserdataVFS('workflows')
await expect(fs.read('/x')).rejects.toThrow(/read failed/)
})
it('empty root lists from user root', async () => {
mocked.listUserDataFullInfo.mockResolvedValue([
{ path: 'settings.json', size: 5, modified: 1 }
])
const fs = new UserdataVFS('')
const entries = await fs.list('/')
expect(entries[0].name).toBe('settings.json')
})
})

View File

@@ -0,0 +1,105 @@
import { api } from '@/scripts/api'
import type { VFS, VfsEntry } from '../types'
function stripLead(p: string): string {
return p.replace(/^\/+/, '')
}
function joinRoot(root: string, rel: string): string {
const base = root.replace(/^\/+|\/+$/g, '')
const suffix = stripLead(rel)
if (!suffix || suffix === '/') return base
return base ? `${base}/${suffix}` : suffix
}
export class UserdataVFS implements VFS {
constructor(private root: string = 'workflows') {}
private toRemote(rel: string): string {
return joinRoot(this.root, rel)
}
async list(path: string): Promise<VfsEntry[]> {
const prefix = this.toRemote(path)
const infos = await api.listUserDataFullInfo(prefix || '.')
const seen = new Map<string, VfsEntry>()
const prefixSlash = prefix ? prefix + '/' : ''
for (const info of infos) {
const rest = info.path.startsWith(prefixSlash)
? info.path.slice(prefixSlash.length)
: info.path
if (!rest) continue
const slash = rest.indexOf('/')
if (slash === -1) {
seen.set(rest, {
name: rest,
path: '/' + info.path,
type: 'file',
size: info.size,
modified: info.modified
})
} else {
const name = rest.slice(0, slash)
if (!seen.has(name)) {
seen.set(name, {
name,
path: '/' + (prefix ? prefix + '/' : '') + name,
type: 'dir'
})
}
}
}
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
}
async read(path: string): Promise<string> {
const resp = await api.getUserData(this.toRemote(path))
if (!resp.ok) throw new Error(`read failed: ${resp.status} ${path}`)
return resp.text()
}
async write(path: string, data: string): Promise<void> {
const resp = await api.storeUserData(this.toRemote(path), data, {
overwrite: true,
stringify: false,
throwOnError: false
})
if (!resp.ok) {
throw new Error(`write failed: ${resp.status} ${path}`)
}
}
async append(path: string, data: string): Promise<void> {
let current = ''
try {
current = await this.read(path)
} catch {
current = ''
}
return this.write(path, current + data)
}
async delete(path: string): Promise<void> {
const resp = await api.deleteUserData(this.toRemote(path))
if (!resp.ok && resp.status !== 404) {
throw new Error(`delete failed: ${resp.status} ${path}`)
}
}
async move(src: string, dest: string): Promise<void> {
const resp = await api.moveUserData(
this.toRemote(src),
this.toRemote(dest),
{ overwrite: false }
)
if (!resp.ok) {
throw new Error(`move failed: ${resp.status} ${src} -> ${dest}`)
}
}
async exists(path: string): Promise<boolean> {
const resp = await api.getUserData(this.toRemote(path), { method: 'HEAD' })
return resp.ok
}
}

View File

@@ -0,0 +1,99 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { IngestedAsset } from './agentStore'
import { useAgentStore } from './agentStore'
function fakeAsset(overrides: Partial<IngestedAsset> = {}): IngestedAsset {
return {
id: crypto.randomUUID(),
name: 'a.png',
path: '/input/a.png',
mime: 'image/png',
size: 10,
...overrides
}
}
describe('useAgentStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('starts closed with no messages', () => {
const s = useAgentStore()
expect(s.isOpen).toBe(false)
expect(s.messages).toEqual([])
expect(s.hasMessages).toBe(false)
})
it('toggle flips open state', () => {
const s = useAgentStore()
s.toggle()
expect(s.isOpen).toBe(true)
s.toggle()
expect(s.isOpen).toBe(false)
})
it('adds message with generated id and timestamp', () => {
const s = useAgentStore()
const m = s.addMessage({ role: 'user', text: 'hi' })
expect(m.id).toMatch(/[0-9a-f-]{36}/)
expect(m.createdAt).toBeGreaterThan(0)
expect(s.messages).toHaveLength(1)
expect(s.hasMessages).toBe(true)
})
it('increments unread for assistant messages while closed', () => {
const s = useAgentStore()
s.addMessage({ role: 'assistant', text: 'reply' })
expect(s.unreadCount).toBe(1)
s.addMessage({ role: 'user', text: 'mine' })
expect(s.unreadCount).toBe(1)
})
it('does not increment unread while open', () => {
const s = useAgentStore()
s.open()
s.addMessage({ role: 'assistant', text: 'reply' })
expect(s.unreadCount).toBe(0)
})
it('open resets unread', () => {
const s = useAgentStore()
s.addMessage({ role: 'assistant', text: 'reply' })
expect(s.unreadCount).toBe(1)
s.open()
expect(s.unreadCount).toBe(0)
})
it('clearMessages empties history', () => {
const s = useAgentStore()
s.addMessage({ role: 'user', text: 'hi' })
s.clearMessages()
expect(s.messages).toEqual([])
})
it('pending assets add / consume / remove', () => {
const s = useAgentStore()
const a = fakeAsset({ id: 'a' })
const b = fakeAsset({ id: 'b' })
s.addPendingAsset(a)
s.addPendingAsset(b)
s.removePendingAsset('a')
expect(s.pendingAssets.map((x) => x.id)).toEqual(['b'])
const consumed = s.consumePendingAssets()
expect(consumed.map((x) => x.id)).toEqual(['b'])
expect(s.pendingAssets).toEqual([])
})
it('fabPosition persists via localStorage', async () => {
const s = useAgentStore()
s.fabPosition = { x: 42, y: 99 }
await new Promise((r) => setTimeout(r, 0))
const raw = localStorage.getItem('Comfy.Agent.FabPosition')
expect(raw).toBeTruthy()
expect(JSON.parse(raw!)).toEqual({ x: 42, y: 99 })
})
})

View File

@@ -0,0 +1,175 @@
import { useLocalStorage } from '@vueuse/core'
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { log } from '../services/logger'
type AgentMessageRole = 'user' | 'assistant' | 'system'
export interface IngestedAsset {
id: string
name: string
path: string
mime: string
size: number
previewUrl?: string
}
interface ToolMessageMeta {
script: string
stdout: string
stderr?: string
exitCode: number
}
interface AgentMessage {
id: string
role: AgentMessageRole
text: string
assets?: IngestedAsset[]
createdAt: number
/**
* Present on system messages that record a tool invocation. Lets the
* renderer fold/unfold individual tool calls by structure instead of
* re-parsing the synthesized text summary.
*/
tool?: ToolMessageMeta
}
interface FabPosition {
x: number
y: number
}
// Cap persisted history so IndexedDB stays lean across sessions. Tool
// output can get verbose — 300 entries is ~months of casual use.
const MAX_PERSISTED_MESSAGES = 300
export const useAgentStore = defineStore('agent', () => {
// IndexedDB-backed: survives reloads, larger quota than localStorage,
// doesn't block the main thread like localStorage sync-writes would.
// Note: useIDBKeyval populates `data` asynchronously, so the initial
// `data.value` is `[]` until the read resolves. We seed `messages` with
// whatever's already there (cheap if it's empty) and then hydrate from
// the DB once the read completes — only after that do we enable the
// write-back watcher, otherwise an early in-memory mutation would
// overwrite real persisted history with the empty seed.
const persisted = useIDBKeyval<AgentMessage[]>('Comfy.Agent.Messages', [], {
shallow: false
})
const messages = ref<AgentMessage[]>([...(persisted.data.value ?? [])])
let hydrated = false
watch(
persisted.isFinished,
(done) => {
if (!done || hydrated) return
hydrated = true
const stored = persisted.data.value ?? []
// If the user already typed before the IDB read resolved, prepend
// stored entries so the new ones come last.
if (messages.value.length === 0) {
messages.value = [...stored]
} else if (stored.length > 0) {
messages.value = [...stored, ...messages.value]
}
},
{ immediate: true }
)
// Sync in-memory → persisted (truncated to the cap). Deep watch so edits
// to message text during streaming also flush. Skip writes until the
// initial DB read has settled, otherwise a pre-hydration mutation
// clobbers the stored history.
watch(
messages,
(next) => {
if (!hydrated) return
persisted.data.value = next.slice(-MAX_PERSISTED_MESSAGES)
},
{ deep: true }
)
const isOpen = ref(false)
const isStreaming = ref(false)
const fabPosition = useLocalStorage<FabPosition>('Comfy.Agent.FabPosition', {
x: 0,
y: 0
})
const pendingAssets = ref<IngestedAsset[]>([])
const unreadCount = ref(0)
const hasMessages = computed(() => messages.value.length > 0)
function open(): void {
isOpen.value = true
unreadCount.value = 0
}
function close(): void {
isOpen.value = false
}
function toggle(): void {
if (isOpen.value) close()
else open()
}
function addMessage(
msg: Omit<AgentMessage, 'id' | 'createdAt'>
): AgentMessage {
const full: AgentMessage = {
...msg,
id: crypto.randomUUID(),
createdAt: Date.now()
}
messages.value.push(full)
// Return the reactive proxy view, NOT the plain object we pushed.
// Vue 3's reactivity wraps array items lazily on read access; mutating
// `full.text` directly bypasses the proxy's set trap and fails to
// trigger watchers (the bug that left assistant streaming silently
// invisible in xterm). Read-through the array index to get the
// proxy-wrapped reference, so callers' mutations fire reactivity.
const reactiveItem = messages.value[messages.value.length - 1]
if (!isOpen.value && msg.role !== 'user') unreadCount.value++
log({ kind: msg.role, text: msg.text })
return reactiveItem
}
function clearMessages(): void {
messages.value = []
}
function addPendingAsset(asset: IngestedAsset): void {
pendingAssets.value.push(asset)
}
function consumePendingAssets(): IngestedAsset[] {
const out = pendingAssets.value
pendingAssets.value = []
return out
}
function removePendingAsset(id: string): void {
pendingAssets.value = pendingAssets.value.filter((a) => a.id !== id)
}
return {
messages,
isOpen,
isStreaming,
fabPosition,
pendingAssets,
unreadCount,
hasMessages,
open,
close,
toggle,
addMessage,
clearMessages,
addPendingAsset,
consumePendingAssets,
removePendingAsset
}
})

113
src/agent/ui/AgentFab.vue Normal file
View File

@@ -0,0 +1,113 @@
<template>
<div
v-show="positioned"
ref="fabEl"
class="agent-fab pointer-events-auto fixed select-none"
data-testid="agent-fab"
:style="[style, { zIndex: 9999 }]"
:class="cn(isDragging && 'cursor-grabbing')"
role="button"
tabindex="0"
:aria-label="t('agent.fab.aria')"
@click="onClick"
@keydown.enter="onClick"
@keydown.space.prevent="onClick"
@dragover.prevent="onDragOver"
@dragleave="isHoveringDrop = false"
@drop.prevent="onDrop"
>
<div
class="relative flex items-center justify-center transition-transform hover:scale-110"
:class="
cn(
isHoveringDrop &&
'scale-110 drop-shadow-[0_0_8px_rgba(240,255,65,0.9)]'
)
"
>
<img
src="/assets/images/comfy-logo-single.svg"
:alt="t('agent.panel.logoAlt')"
class="size-12 drop-shadow-[0_2px_6px_rgba(0,0,0,0.5)] select-none"
draggable="false"
/>
<span
v-if="store.unreadCount > 0"
class="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-electric-400 text-xs font-bold text-charcoal-800"
>
{{ store.unreadCount > 9 ? '9+' : store.unreadCount }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useDraggable, watchDebounced } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useAssetIngest } from '../composables/useAssetIngest'
import { useAgentStore } from '../stores/agentStore'
const { t } = useI18n()
const store = useAgentStore()
const { ingestFromClipboard } = useAssetIngest()
const fabEl = ref<HTMLElement | null>(null)
const isHoveringDrop = ref(false)
const positioned = ref(false)
const { x, y, style, isDragging } = useDraggable(fabEl, {
initialValue: store.fabPosition,
containerElement: document.body,
preventDefault: true
})
let didDrag = false
watchDebounced(
[x, y],
([nx, ny]) => {
store.fabPosition = { x: nx, y: ny }
},
{ debounce: 300 }
)
onMounted(() => {
const el = fabEl.value
if (!el) return
const w = el.offsetWidth || 48
const h = el.offsetHeight || 48
if (store.fabPosition.x === 0 && store.fabPosition.y === 0) {
x.value = window.innerWidth - w - 24
y.value = window.innerHeight - h - 24
} else {
x.value = clamp(store.fabPosition.x, 0, window.innerWidth - w)
y.value = clamp(store.fabPosition.y, 0, window.innerHeight - h)
}
positioned.value = true
})
function onClick(): void {
if (isDragging.value || didDrag) {
didDrag = false
return
}
store.toggle()
}
function onDragOver(e: DragEvent): void {
isHoveringDrop.value = true
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
async function onDrop(e: DragEvent): Promise<void> {
isHoveringDrop.value = false
const results = await ingestFromClipboard(e.dataTransfer)
for (const r of results) store.addPendingAsset(r.asset)
if (results.length > 0) store.open()
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<Teleport to="body">
<AgentFab />
<FoldablePanel />
</Teleport>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useLocalBridge } from '../composables/useLocalBridge'
import { useAgentStore } from '../stores/agentStore'
import AgentFab from './AgentFab.vue'
import FoldablePanel from './FoldablePanel.vue'
useLocalBridge()
onMounted(() => {
const commandStore = useCommandStore()
const keybindingStore = useKeybindingStore()
const agentStore = useAgentStore()
// Register the toggle command idempotently — hot-reload may remount.
if (!commandStore.isRegistered('Comfy.Agent.Toggle')) {
commandStore.registerCommand({
id: 'Comfy.Agent.Toggle',
label: 'Toggle ComfyAI Agent',
menubarLabel: 'Toggle ComfyAI',
icon: 'pi pi-sparkles',
function: () => {
agentStore.toggle()
}
})
}
// Single-key 'c' — matches the single-key style of 'r' (refresh) and
// 'w' (workflows sidebar). Wrapped in try/catch because addDefaultKeybinding
// throws on duplicates.
try {
keybindingStore.addDefaultKeybinding(
new KeybindingImpl({
commandId: 'Comfy.Agent.Toggle',
combo: { key: 'c' }
})
)
} catch {
/* already registered */
}
})
</script>

View File

@@ -0,0 +1,216 @@
<template>
<div class="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
<!-- Frontend-only reassurance + onboarding hint when no key -->
<div
class="rounded-sm border border-azure-600/40 bg-azure-600/10 px-2.5 py-2 text-xs text-(--fg-color)"
>
<p class="leading-snug">
<span class="font-semibold">{{
t('agent.settings.frontendOnly')
}}</span>
{{ t('agent.settings.frontendOnlyHint') }}
</p>
<p v-if="!apiKey" class="mt-1 leading-snug text-electric-400">
{{ t('agent.settings.noKeyWarning') }}
</p>
</div>
<!-- Compact 3-field row: API base / API key / Model -->
<section class="flex flex-col gap-2">
<label
for="agent-baseurl"
class="text-xs font-medium text-muted-foreground"
>
{{ t('agent.settings.baseUrl') }}
</label>
<input
id="agent-baseurl"
v-model="baseURL"
type="url"
autocomplete="off"
spellcheck="false"
class="border-default rounded-sm border bg-secondary-background px-2 py-1.5 font-mono text-xs focus:ring-1 focus:ring-(--border-default) focus:outline-none"
:placeholder="baseUrlPlaceholder"
/>
<p class="text-xs text-muted-foreground">
{{ t('agent.settings.baseUrlHint') }}
</p>
<label
for="agent-apikey"
class="mt-1 text-xs font-medium text-muted-foreground"
>
{{ t('agent.settings.apiKey') }}
</label>
<input
id="agent-apikey"
v-model="apiKey"
type="password"
autocomplete="off"
spellcheck="false"
class="border-default rounded-sm border bg-secondary-background px-2 py-1.5 font-mono text-xs focus:ring-1 focus:ring-(--border-default) focus:outline-none"
:placeholder="apiKeyPlaceholder"
/>
<p class="text-xs text-muted-foreground">
{{ t('agent.settings.apiKeyHint') }}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
class="text-azure-400 underline hover:text-azure-300"
>{{ t('agent.settings.apiKeyLinkOpenAI') }}</a
>
{{ t('agent.settings.apiKeyOr') }}
<a
href="https://openrouter.ai/workspaces/default/keys"
target="_blank"
rel="noopener noreferrer"
class="text-azure-400 underline hover:text-azure-300"
>{{ t('agent.settings.apiKeyLinkOpenRouter') }}</a
>
{{ t('agent.settings.apiKeyOrAny') }}
</p>
<label
for="agent-model"
class="mt-1 text-xs font-medium text-muted-foreground"
>
{{ t('agent.settings.model') }}
</label>
<input
id="agent-model"
v-model="model"
type="text"
autocomplete="off"
spellcheck="false"
class="border-default rounded-sm border bg-secondary-background px-2 py-1.5 font-mono text-xs focus:ring-1 focus:ring-(--border-default) focus:outline-none"
:placeholder="t('agent.settings.modelPlaceholder')"
/>
<p class="text-xs text-muted-foreground">
{{ t('agent.settings.modelHint') }}
</p>
</section>
<!-- Local agent bridge -->
<section class="border-default border-t pt-3">
<p class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('agent.settings.localBridge') }}
</p>
<div class="flex items-center gap-2">
<span
:class="
cn(
'inline-flex size-2 shrink-0 rounded-full',
connected ? 'bg-emerald-400' : 'bg-muted-foreground/40'
)
"
/>
<span class="text-xs text-muted-foreground">
{{
connected
? t('agent.settings.bridgeConnected')
: t('agent.settings.bridgeDisconnected')
}}
</span>
<button
v-if="connected && !activePairCode"
class="ml-auto rounded-sm border border-azure-600/40 bg-azure-600/10 px-2 py-0.5 text-xs text-azure-400 hover:bg-azure-600/20"
@click="requestPair()"
>
{{ t('agent.settings.bridgePair') }}
</button>
</div>
<div
v-if="activePairCode"
class="border-default mt-2 rounded-sm border bg-secondary-background/60 p-2 text-xs"
>
<p class="mb-1 text-muted-foreground">
{{ t('agent.settings.bridgePairHint') }}
</p>
<code class="block font-mono break-all text-azure-300 select-all"
>comfy-ai pair http://127.0.0.1:7437/pair/{{ activePairCode }}</code
>
<p class="mt-1.5 text-muted-foreground/70">
{{ t('agent.settings.bridgePairWaiting') }}
</p>
</div>
</section>
<details class="border-default border-t pt-3 text-sm">
<summary
class="cursor-pointer text-xs font-medium text-muted-foreground select-none"
>
{{ t('agent.settings.advanced') }}
</summary>
<section class="mt-2 flex flex-col gap-1">
<label
for="agent-reasoning"
class="text-xs font-medium text-muted-foreground"
>
{{ t('agent.settings.reasoning') }}
</label>
<select
id="agent-reasoning"
v-model="reasoningEffort"
class="border-default rounded-sm border bg-secondary-background px-2 py-1.5 text-sm focus:ring-1 focus:ring-(--border-default) focus:outline-none"
>
<option value="minimal">
{{ t('agent.settings.reasoningMinimal') }}
</option>
<option value="low">{{ t('agent.settings.reasoningLow') }}</option>
<option value="medium">
{{ t('agent.settings.reasoningMedium') }}
</option>
<option value="high">
{{ t('agent.settings.reasoningHigh') }}
</option>
</select>
<p class="text-xs text-muted-foreground">
{{ t('agent.settings.reasoningHint') }}
</p>
</section>
<section class="mt-3 flex flex-1 flex-col gap-1">
<label
for="agent-sysprompt"
class="text-xs font-medium text-muted-foreground"
>
{{ t('agent.settings.systemPrompt') }}
</label>
<textarea
id="agent-sysprompt"
v-model="systemPromptAppend"
rows="6"
class="border-default flex-1 resize-none rounded-sm border bg-secondary-background px-2 py-1.5 font-mono text-xs focus:ring-1 focus:ring-(--border-default) focus:outline-none"
:placeholder="t('agent.settings.systemPromptPlaceholder')"
/>
<p class="text-xs text-muted-foreground">
{{ t('agent.settings.systemPromptHint') }}
</p>
</section>
</details>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useBridgeStatus } from '../composables/useLocalBridge'
import { useAgentSession } from '../composables/useAgentSession'
const { t } = useI18n()
const { apiKey, baseURL, model, reasoningEffort, systemPromptAppend } =
useAgentSession()
const { connected, activePairCode, requestPair } = useBridgeStatus()
const apiKeyPlaceholder = computed(() =>
apiKey.value ? '•••••• (stored)' : 'sk-... or sk-or-...'
)
const baseUrlPlaceholder = computed(
() => 'https://api.openai.com/v1 (default — leave blank for OpenAI)'
)
</script>

View File

@@ -0,0 +1,744 @@
<template>
<div
v-if="store.isOpen"
v-show="positioned"
ref="panelEl"
class="agent-foldable-panel border-default/30 pointer-events-auto fixed flex flex-col rounded-lg border bg-comfy-menu-bg/80 shadow-2xl backdrop-blur-xl backdrop-saturate-150"
data-testid="agent-panel"
:style="[
panelStyle,
{
zIndex: 9998,
width: size.width + 'px',
height: size.height + 'px'
}
]"
>
<header
ref="dragHandleRef"
class="border-default/30 flex cursor-grab items-center justify-between border-b px-3 py-2 select-none active:cursor-grabbing"
>
<div class="flex items-center gap-2">
<img
src="/assets/images/comfy-logo-single.svg"
:alt="t('agent.panel.logoAlt')"
:class="cn('size-4 select-none', store.isStreaming && 'animate-spin')"
draggable="false"
/>
<span
v-if="!showSettings"
data-testid="agent-panel-title"
class="rounded-sm bg-charcoal-700 px-1.5 py-0.5 font-serif text-xs font-semibold tracking-wider text-electric-400 italic"
>
{{ t('agent.panel.brandTitle') }}
</span>
<span v-else class="text-sm font-medium text-(--fg-color)">
{{ t('agent.settings.title') }}
</span>
</div>
<div class="flex items-center gap-0.5">
<button
v-if="!showSettings"
:class="iconBtnClass(false)"
:title="
allFolded ? t('agent.panel.unfoldAll') : t('agent.panel.foldAll')
"
@click.stop="toggleAllFolds"
>
<i
:class="
cn(
'size-3.5',
allFolded
? 'icon-[lucide--unfold-vertical]'
: 'icon-[lucide--fold-vertical]'
)
"
/>
</button>
<button
v-if="!showSettings && store.isStreaming"
:class="iconBtnClass(false)"
:aria-label="t('agent.panel.stop')"
@click.stop="session.stop()"
>
<i class="icon-[lucide--square] size-3.5" />
</button>
<button
v-if="!showSettings"
:class="iconBtnClass(false)"
:aria-label="t('agent.panel.clear')"
@click.stop="clearAll()"
>
<i class="icon-[lucide--eraser] size-3.5" />
</button>
<button
:class="iconBtnClass(showSettings)"
:aria-label="t('agent.panel.settings')"
:aria-pressed="showSettings"
@click.stop="showSettings = !showSettings"
>
<i
:class="
cn(
'size-3.5',
showSettings
? 'icon-[lucide--terminal]'
: 'icon-[lucide--settings]'
)
"
/>
</button>
<button
:class="iconBtnClass(false, true)"
:aria-label="t('agent.panel.close')"
@click.stop="store.close()"
>
<i class="icon-[lucide--x] size-3.5" />
</button>
</div>
</header>
<AgentSettings v-if="showSettings" />
<div
v-else
class="terminal-host relative flex flex-1 flex-col overflow-hidden"
data-testid="agent-terminal"
@dragover.prevent.capture="isHovering = true"
@dragleave.capture="isHovering = false"
@drop.prevent.stop.capture="onDrop"
@paste.capture="onPaste"
>
<div
ref="scrollEl"
class="flex-1 overflow-y-auto p-2 font-mono text-xs/snug"
@scroll="onScroll"
@mousedown="onScrollMouseDown"
>
<div v-for="m in store.messages" :key="m.id" class="agent-block">
<div
v-if="m.role === 'user'"
class="my-1 wrap-break-word whitespace-pre-wrap text-azure-400"
>
<span class="opacity-60 select-none">&gt; </span>{{ m.text }}
</div>
<div
v-else-if="m.role === 'assistant'"
class="my-1 wrap-break-word whitespace-pre-wrap text-(--fg-color)"
>
{{ m.text || (store.isStreaming ? '…' : '') }}
</div>
<div
v-else-if="m.tool"
:class="
cn(
'border-default/30 my-1 rounded-sm border bg-secondary-background/40 transition',
'hover:border-default/60'
)
"
>
<button
:class="
cn(
'flex w-full items-center gap-1.5 px-2 py-1 text-left',
'hover:bg-secondary-background/70'
)
"
@click="toggleFold(m.id)"
>
<i
:class="
cn(
'size-3 shrink-0',
isFolded(m.id)
? 'icon-[lucide--chevron-right]'
: 'icon-[lucide--chevron-down]'
)
"
/>
<span class="opacity-60 select-none">$</span>
<span class="flex-1 truncate text-(--fg-color)">{{
summariseScript(m.tool.script)
}}</span>
<span
:class="
cn(
'shrink-0 text-xs tabular-nums',
m.tool.exitCode === 0
? 'text-emerald-400'
: 'text-coral-500'
)
"
>
{{
t('agent.panel.toolFolded', {
count: countLines(m.tool.stdout, m.tool.stderr),
exit: m.tool.exitCode
})
}}
</span>
</button>
<div
v-if="!isFolded(m.id)"
class="border-default/30 border-t px-2 py-1.5"
>
<pre
v-if="m.tool.stdout"
class="wrap-break-word whitespace-pre-wrap text-(--fg-color)/85"
>{{ m.tool.stdout }}</pre
>
<pre
v-if="m.tool.stderr"
class="mt-1 wrap-break-word whitespace-pre-wrap text-coral-500"
>
[stderr] {{ m.tool.stderr }}</pre
>
</div>
</div>
<div
v-else
class="my-1 wrap-break-word whitespace-pre-wrap text-muted-foreground/70"
>
{{ m.text }}
</div>
</div>
<div
v-if="store.messages.length === 0"
class="text-muted-foreground/70"
>
{{ t('agent.panel.prompt') }} {{ t('agent.panel.brandTitle') }}
{{ t('agent.panel.readyHint') }}
</div>
<div
v-if="store.pendingAssets.length > 0"
class="my-1 flex flex-wrap gap-1"
>
<div
v-for="asset in store.pendingAssets"
:key="asset.id"
class="group flex items-center gap-1 rounded-sm bg-secondary-background/60 px-1.5 py-0.5 text-xs"
>
<img
v-if="asset.previewUrl"
:src="asset.previewUrl"
:alt="asset.name"
class="size-5 rounded-sm object-cover"
/>
<i v-else class="icon-[lucide--file] size-3" />
<span class="max-w-32 truncate">{{ asset.path }}</span>
<button
class="opacity-50 hover:opacity-100"
:aria-label="t('agent.input.removeAsset')"
@click="store.removePendingAsset(asset.id)"
>
<i class="icon-[lucide--x] size-3" />
</button>
</div>
</div>
<!--
Inline prompt visually flows as the next line of scrollback
rather than a separate input widget. Same font / colour scheme
as user-message blocks; no border, no background.
-->
<div class="agent-prompt-row flex items-start gap-1.5">
<span class="text-azure-400 select-none">{{
t('agent.panel.prompt')
}}</span>
<textarea
ref="inputEl"
v-model="inputText"
rows="1"
autocomplete="off"
spellcheck="false"
:placeholder="
store.isStreaming
? t('agent.panel.streamingPlaceholder')
: t('agent.panel.inputPlaceholder')
"
:class="
cn(
'flex-1 resize-none border-0 bg-transparent p-0 font-mono text-xs/snug',
'text-(--fg-color) placeholder:text-muted-foreground/50',
'focus:ring-0 focus:outline-none'
)
"
@keydown="onInputKey"
@input="autoGrow"
/>
</div>
</div>
<div
v-if="isHovering"
class="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-azure-600 bg-azure-600/10 text-sm text-white"
>
{{ t('agent.panel.dropHint') }}
</div>
</div>
<div
class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 'e')"
/>
<div
class="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 's')"
/>
<div
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 'w')"
/>
<div
class="absolute inset-x-0 top-0 h-1.5 cursor-ns-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 'n')"
/>
<div
class="absolute right-0 bottom-0 size-3 cursor-se-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 'se')"
/>
<div
class="absolute bottom-0 left-0 size-3 cursor-sw-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 'sw')"
/>
<div
class="absolute top-0 right-0 size-3 cursor-ne-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 'ne')"
/>
<div
class="absolute top-0 left-0 size-3 cursor-nw-resize"
:aria-label="t('agent.panel.resize')"
@pointerdown.stop="(e) => startResize(e, 'nw')"
/>
</div>
</template>
<script setup lang="ts">
import { useDraggable, useLocalStorage, watchDebounced } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useAssetIngest } from '../composables/useAssetIngest'
import { useAgentSession } from '../composables/useAgentSession'
import { dropImageAsLoadImageNode } from '../composables/useImageNodeDrop'
import { log as logEntry } from '../services/logger'
import { useAgentStore } from '../stores/agentStore'
import AgentSettings from './AgentSettings.vue'
const PANEL_W = 560
const PANEL_H = 560
const PANEL_MIN_W = 320
const PANEL_MIN_H = 240
const HISTORY_KEY = 'Comfy.Agent.InputHistory'
const MAX_HISTORY = 100
const { t } = useI18n()
const store = useAgentStore()
const session = useAgentSession()
const { ingestFromClipboard } = useAssetIngest()
const panelEl = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLTextAreaElement | null>(null)
const showSettings = ref(!session.apiKey.value)
const positioned = ref(false)
const isHovering = ref(false)
// Tool messages start folded so the scrollback stays compact. Track per-id
// override so users can pin individual blocks open even when the global
// "fold all" toggle is on.
const explicitFold = ref<Map<string, boolean>>(new Map())
const allFolded = ref(true)
function isFolded(id: string): boolean {
const explicit = explicitFold.value.get(id)
if (explicit !== undefined) return explicit
return allFolded.value
}
function toggleFold(id: string): void {
explicitFold.value.set(id, !isFolded(id))
// Force reactivity on Map mutation
explicitFold.value = new Map(explicitFold.value)
}
function toggleAllFolds(): void {
allFolded.value = !allFolded.value
// Reset per-id overrides so the global state actually applies everywhere.
explicitFold.value = new Map()
}
const inputText = ref('')
const inputHistory = useLocalStorage<string[]>(HISTORY_KEY, [])
const historyIndex = ref<number | null>(null)
const savedPos = useLocalStorage('Comfy.Agent.PanelPosition', { x: 0, y: 0 })
const size = useLocalStorage('Comfy.Agent.PanelSize', {
width: PANEL_W,
height: PANEL_H
})
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
function startResize(e: PointerEvent, dir: ResizeDir): void {
const startX = e.clientX
const startY = e.clientY
const startW = size.value.width
const startH = size.value.height
const startPosX = x.value
const startPosY = y.value
const movesX = dir.includes('w')
const movesY = dir.includes('n')
const onMove = (ev: PointerEvent) => {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
let newW = startW
let newH = startH
if (dir.includes('e')) newW = Math.max(PANEL_MIN_W, startW + dx)
if (dir.includes('w')) newW = Math.max(PANEL_MIN_W, startW - dx)
if (dir.includes('s')) newH = Math.max(PANEL_MIN_H, startH + dy)
if (dir.includes('n')) newH = Math.max(PANEL_MIN_H, startH - dy)
size.value = { width: newW, height: newH }
if (movesX) x.value = startPosX + (startW - newW)
if (movesY) y.value = startPosY + (startH - newH)
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const {
x,
y,
style: panelStyle
} = useDraggable(panelEl, {
initialValue: savedPos.value,
handle: dragHandleRef,
containerElement: document.body
})
watchDebounced(
[x, y],
([nx, ny]) => {
savedPos.value = { x: nx, y: ny }
},
{ debounce: 300 }
)
function setDefaultPosition(): void {
const w = size.value.width
const h = size.value.height
if (savedPos.value.x === 0 && savedPos.value.y === 0) {
x.value = Math.max(0, window.innerWidth - w - 400)
y.value = Math.max(0, window.innerHeight - h - 24)
} else {
x.value = clamp(savedPos.value.x, 0, window.innerWidth - w)
y.value = clamp(savedPos.value.y, 0, window.innerHeight - h)
}
positioned.value = true
}
function iconBtnClass(active: boolean, danger = false): string {
return cn(
'flex size-7 items-center justify-center rounded-md border border-transparent text-muted-foreground transition',
active
? 'border-azure-600/60 bg-azure-600/20 text-azure-600'
: danger
? 'hover:border-coral-500/40 hover:bg-coral-500/15 hover:text-coral-500'
: 'hover:border-default/40 hover:bg-secondary-background/60 hover:text-(--fg-color)',
'focus-visible:ring-2 focus-visible:ring-azure-600 focus-visible:outline-none active:scale-95'
)
}
function summariseScript(script: string): string {
// Single line preview — collapse any internal newlines, trim long lines.
const single = script.replace(/\s+/g, ' ').trim()
return single.length > 200 ? single.slice(0, 200) + '…' : single
}
function countLines(stdout: string, stderr?: string): number {
let n = 0
if (stdout) n += stdout.split('\n').filter((l) => l.length > 0).length
if (stderr) n += stderr.split('\n').filter((l) => l.length > 0).length
return n
}
const userScrolledUp = ref(false)
function onScroll(): void {
const el = scrollEl.value
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
// 80px slack so micro-scrolls during streaming still count as "at bottom"
userScrolledUp.value = distanceFromBottom > 80
}
function scrollToBottom(force = false): void {
void nextTick(() => {
const el = scrollEl.value
if (!el) return
if (force || !userScrolledUp.value) {
el.scrollTop = el.scrollHeight
}
})
}
watch(
() => store.messages.length,
() => scrollToBottom()
)
watch(
() => store.messages.map((m) => m.text).join('\n').length,
() => scrollToBottom()
)
function isDirectShellCommand(line: string): boolean {
const first = line.trim().split(/\s+/)[0]
if (!first) return false
const ctx = session.buildExecContextOnce()
// Don't treat a leading '/' as a shell-redirection sigil — the
// attachment flow prefills the composer with paths like '/input/foo.png'
// or '/tmp/x.json' and pressing Enter would route those to exec instead
// of the LLM. Real shell operators (|, &, ;, <, >) are still honoured.
return !!ctx.registry.get(first) || /^[|&;<>]/.test(first)
}
async function handleSubmit(line: string): Promise<void> {
const trimmed = line.trim()
if (!trimmed && store.pendingAssets.length === 0) return
if (trimmed) {
inputHistory.value = [
...inputHistory.value.filter((h) => h !== trimmed),
trimmed
].slice(-MAX_HISTORY)
}
historyIndex.value = null
const assets = store.consumePendingAssets()
if (trimmed && isDirectShellCommand(trimmed)) {
logEntry({ kind: 'user', text: trimmed })
store.addMessage({ role: 'user', text: trimmed })
try {
const result = await session.execShell(trimmed)
logEntry({
kind: 'tool',
script: trimmed,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode
})
store.addMessage({
role: 'system',
text: `$ ${trimmed}\n${result.stdout}${result.stderr ? `\n[stderr] ${result.stderr}` : ''}`,
tool: {
script: trimmed,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode
}
})
} catch (err) {
const text = err instanceof Error ? err.message : String(err)
logEntry({ kind: 'error', text })
store.addMessage({ role: 'system', text: `error: ${text}` })
}
return
}
await session.send(trimmed, assets)
}
function autoGrow(): void {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
}
async function onInputKey(e: KeyboardEvent): Promise<void> {
if (e.key === 'Enter' && !e.shiftKey && !e.altKey) {
e.preventDefault()
if (store.isStreaming) return
const line = inputText.value
inputText.value = ''
autoGrow()
await handleSubmit(line)
return
}
if (e.key === 'ArrowUp' && (inputText.value === '' || e.altKey)) {
e.preventDefault()
const hist = inputHistory.value
if (hist.length === 0) return
historyIndex.value =
historyIndex.value === null
? hist.length - 1
: Math.max(0, historyIndex.value - 1)
inputText.value = hist[historyIndex.value] ?? ''
void nextTick(autoGrow)
return
}
if (e.key === 'ArrowDown' && historyIndex.value !== null) {
e.preventDefault()
const hist = inputHistory.value
historyIndex.value = historyIndex.value + 1
if (historyIndex.value >= hist.length) {
historyIndex.value = null
inputText.value = ''
} else {
inputText.value = hist[historyIndex.value] ?? ''
}
void nextTick(autoGrow)
return
}
if (e.key === 'l' && e.ctrlKey) {
e.preventDefault()
clearAll()
}
}
function clearAll(): void {
store.clearMessages()
explicitFold.value = new Map()
allFolded.value = true
inputEl.value?.focus()
}
function focusInput(): void {
void nextTick(() => inputEl.value?.focus())
}
/**
* Click anywhere in the scrollback — but only on the bare container
* itself, not on a message — focuses the input. Mirrors how a real
* terminal lets you keep typing after scrolling away.
*/
function onScrollMouseDown(e: MouseEvent): void {
const target = e.target as HTMLElement | null
if (!target) return
if (target === scrollEl.value) {
focusInput()
}
}
async function onDrop(e: DragEvent): Promise<void> {
isHovering.value = false
const dt = e.dataTransfer
if (!dt) return
const text =
dt.getData('text/plain') ||
dt.getData('text') ||
dt.getData('text/uri-list')
if (text && (!dt.files || dt.files.length === 0)) {
inputText.value += text
void nextTick(autoGrow)
focusInput()
return
}
const results = await ingestFromClipboard(dt)
for (const r of results) {
store.addPendingAsset(r.asset)
const isImage = r.asset.mime.startsWith('image/')
if (isImage && r.remote) {
const filename = r.asset.path.replace(/^\/input\/?/, '')
const nodeId = dropImageAsLoadImageNode(filename)
store.addMessage({
role: 'system',
text:
nodeId !== null
? `[+] LoadImage #${nodeId}${filename}`
: `(uploaded ${filename} — could not add LoadImage node)`
})
} else {
inputText.value += r.asset.path + ' '
}
}
void nextTick(autoGrow)
focusInput()
}
async function onPaste(e: ClipboardEvent): Promise<void> {
if (!e.clipboardData) return
const hasFiles = Array.from(e.clipboardData.items).some(
(i) => i.kind === 'file'
)
if (!hasFiles) return
e.stopPropagation()
e.preventDefault()
const results = await ingestFromClipboard(e.clipboardData)
for (const r of results) {
store.addPendingAsset(r.asset)
inputText.value += r.asset.path + ' '
}
void nextTick(autoGrow)
}
watch(
() => store.isOpen,
(open) => {
if (!open) return
void nextTick(() => {
setDefaultPosition()
void nextTick(() => {
scrollToBottom(true)
focusInput()
})
})
},
{ immediate: true }
)
// Global Ctrl+O / ⌘+O handler — registered on window so the browser's
// default "Open File" dialog can be preempted regardless of which element
// inside the panel currently has focus. Only acts while the panel is open.
function handleGlobalKey(e: KeyboardEvent): void {
if (!store.isOpen) return
if (e.key !== 'o' && e.key !== 'O') return
if (!(e.ctrlKey || e.metaKey)) return
if (e.altKey || e.shiftKey) return
e.preventDefault()
e.stopPropagation()
toggleAllFolds()
}
onMounted(() => {
window.addEventListener('keydown', handleGlobalKey, { capture: true })
if (store.isOpen) {
setDefaultPosition()
void nextTick(() => {
scrollToBottom(true)
focusInput()
})
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKey, { capture: true })
})
</script>
<style scoped>
.agent-block pre {
font-family: inherit;
font-size: inherit;
line-height: inherit;
margin: 0;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="relative">
<code
class="block rounded-md bg-neutral-800 p-3 pr-10 text-xs whitespace-pre-wrap text-neutral-200 select-all"
>{{ text }}</code
>
<button
:title="copied ? t('clipboard.successMessage') : t('g.copyToClipboard')"
:aria-label="
copied ? t('clipboard.successMessage') : t('g.copyToClipboard')
"
class="absolute top-2 right-2 rounded-sm p-1 text-neutral-500 transition-colors hover:text-neutral-100"
@click="copy(text)"
>
<span
:class="
copied ? 'icon-[lucide--check] text-green-400' : 'icon-[lucide--copy]'
"
class="block size-3.5"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
const { text } = defineProps<{ text: string }>()
const { t } = useI18n()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
</script>

View File

@@ -42,6 +42,19 @@
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
<template v-if="badge.popoverLinks?.length">
<hr class="border-border-default" />
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</template>
</div>
</Popover>
</div>
@@ -96,6 +109,19 @@
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
<template v-if="badge.popoverLinks?.length">
<hr class="border-border-default" />
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</template>
</div>
</Popover>
</div>
@@ -103,10 +129,15 @@
<!-- Full mode: Icon + Label + Text -->
<div
v-else
v-tooltip="badge.tooltip"
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
v-tooltip="badge.popoverLinks?.length ? undefined : badge.tooltip"
class="relative flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[
{ 'flex-row-reverse': reverseOrder },
noPadding ? '' : 'px-3',
badge.popoverLinks?.length ? clickableClasses : ''
]"
:style="menuBackgroundStyle"
@click="badge.popoverLinks?.length ? togglePopover($event) : undefined"
>
<i
v-if="iconClass"
@@ -123,6 +154,30 @@
<div class="font-inter text-sm" :class="textClasses">
{{ badge.text }}
</div>
<Popover
v-if="badge.popoverLinks?.length"
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="popoverPt"
>
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</div>
</Popover>
</div>
</template>
<script setup lang="ts">

View File

@@ -41,3 +41,15 @@ export function getComfyPlatformBaseUrl(): string {
BUILD_TIME_PLATFORM_BASE_URL
)
}
/**
* Maps a Comfy Cloud API base URL (as reported by the backend) to its paired
* platform URL where users manage their account / API keys. Returns null for
* unknown bases so callers can hide the link rather than guess.
*/
export function getPlatformBaseUrlForApiBase(apiBase: string): string | null {
const normalized = apiBase.replace(/\/+$/, '')
if (normalized === PROD_API_BASE_URL) return PROD_PLATFORM_BASE_URL
if (normalized === STAGING_API_BASE_URL) return STAGING_PLATFORM_BASE_URL
return null
}

View File

@@ -52,3 +52,8 @@ if (isCloud || isNightly) {
if (isNightly && !isCloud) {
await import('./nightlyBadges')
}
// PR preview build badge
if (__CI_PR_NUMBER__) {
await import('./prPreviewBadges')
}

View File

@@ -0,0 +1,82 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionService } from '@/services/extensionService'
import type { AboutPageBadge, TopbarBadge } from '@/types/comfy'
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
const prNumber = __CI_PR_NUMBER__
const author = __CI_PR_AUTHOR__
const commit = __COMFYUI_FRONTEND_COMMIT__
const commitShort = commit ? commit.slice(0, 8) : ''
const settingStore = useSettingStore()
const apiNodesEnabled = settingStore.get('Comfy.NodeBadge.ShowApiPricing')
const backendUrl = localStorage.getItem('comfyui-preview-backend-url') ?? '—'
const tooltipLines = [
author ? `@${author}` : null,
commitShort ? commitShort : null,
`${t('prPreview.badge.tooltipBackendLabel')}${backendUrl}`,
apiNodesEnabled
? t('prPreview.badge.tooltipCloudApiNote')
: t('prPreview.badge.tooltipCloudApiDisabled')
]
.filter(Boolean)
.join(' · ')
const popoverLinks = [
{ label: `PR #${prNumber}`, url: `${REPO}/pull/${prNumber}` },
...(author
? [{ label: `@${author}`, url: `https://github.com/${author}` }]
: []),
...(commitShort
? [{ label: commitShort, url: `${REPO}/commit/${commit}` }]
: []),
{ label: t('prPreview.badge.configureBackend'), url: '/connect' }
]
const badgeText = commitShort ? `#${prNumber} · ${commitShort}` : `#${prNumber}`
const topbarBadges: TopbarBadge[] = [
{
label: t('prPreview.badge.label'),
text: badgeText,
variant: 'warning',
tooltip: tooltipLines,
popoverLinks
}
]
const aboutPageBadges: AboutPageBadge[] = [
{
label: `PR #${prNumber}`,
url: `${REPO}/pull/${prNumber}`,
icon: 'pi pi-github'
},
...(author
? [
{
label: `@${author}`,
url: `https://github.com/${author}`,
icon: 'pi pi-user'
}
]
: []),
...(commitShort
? [
{
label: commitShort,
url: `${REPO}/commit/${commit}`,
icon: 'pi pi-code'
}
]
: [])
]
useExtensionService().registerExtension({
name: 'Comfy.PrPreview.Badges',
topbarBadges,
aboutPageBadges
})

View File

@@ -3798,5 +3798,135 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"connectionPanel": {
"title": "ComfyUI Frontend Preview",
"subtitle": "Connect to a running ComfyUI backend to use this preview.",
"previewWarningTitle": "⚠ This is a preview build of an in-flight pull request.",
"previewWarningBody": "The UI may change rapidly as the branch is pushed and is under heavy development. Use it for testing and review only — never rely on any *.comfy-ui.pages.dev URL for production work.",
"previewProvenance": "Built from {pr} ({commit}) by {author}.",
"previewUnknownAuthor": "an unknown author",
"previewTrustWarning": "Do not connect a ComfyUI instance you care about unless you trust the author of this PR — a malicious frontend can read and modify any workflow, model, or output on the connected backend.",
"backendUrl": "Backend URL",
"apiKey": "Comfy API Key",
"apiKeyOptional": "(optional)",
"apiKeyPlaceholder": "sk-...",
"apiKeyHint": "Only needed for cloud-API nodes (e.g. Flux, Kling). Saved to your browser.",
"apiKeyTestOk": "API key is valid.",
"apiKeyTestError": "Invalid or expired API key.",
"apiKeyDisabledNotice": "The connected backend was started with --disable-api-nodes; cloud-API nodes are unavailable.",
"test": "Test",
"http": "HTTP",
"ws": "WS",
"status": "Connection Status",
"connected": "Connected — backend is reachable.",
"backendCloud": "Backend cloud API:",
"cloudMismatch": "⚠ Cloud environment mismatch — this preview signs in via {frontend}, so tokens won't be accepted by the backend. Restart the backend with --comfy-api-base={frontend} (or use a frontend build that targets the backend's environment).",
"getApiKeyLink": "→ Generate an API key for this cloud",
"connectAndGo": "Connect & Open ComfyUI",
"quickStart": "Quick Start with Comfy CLI",
"quickStartDescription": "The fastest way to get ComfyUI running locally. No existing Python install required — uv handles it for you.",
"step1InstallUv": "1. Install uv (macOS/Linux, then Windows):",
"uvNote": "uv is a fast Python package manager that auto-installs Python itself, so you don't need Python preinstalled. After install, restart your terminal.",
"step2InstallComfyui": "2. Install comfy-cli and ComfyUI:",
"managerIncludedNote": "This also installs ComfyUI-Manager by default — it makes downloading missing models and custom nodes one-click, so workflows from others just work.",
"managerTitle": "Why ComfyUI-Manager?",
"managerDescription": "ComfyUI-Manager is bundled with comfy install. It auto-detects missing custom nodes and models referenced by any workflow you load, then installs them for you in one click — no more hunting GitHub repos or Hugging Face links by hand.",
"managerLearnMore": "Learn more about ComfyUI-Manager →",
"step3Launch": "3. Launch with CORS enabled:",
"altManualSetup": "Alternative: I already have Python installed",
"altPipDescription": "If you already have Python 3.10+ and pip available, you can install comfy-cli directly:",
"altPipNote": "Note: older Python versions (<3.10) may fail to install some comfy-cli dependencies.",
"altManagerDescription": "If you cloned ComfyUI manually, also install ComfyUI-Manager into custom_nodes/:",
"guideDescription": "If you already have ComfyUI cloned, start it with CORS enabled from the repo root:",
"corsNote": "The --enable-cors-header flag allows this preview page to communicate with your local backend.",
"corsOriginNote": "The exact origin is pre-filled so ComfyUI can allow requests from this specific preview URL. Using * would block requests that include credentials.",
"localAccess": "Local Network Access",
"localAccessDescription": "Your browser may prompt for permission to access local network devices. Allow it so this page can reach your local ComfyUI instance.",
"localAccessListenDescription": "To connect from another device on the same network (e.g. a phone or second computer), pass --listen so ComfyUI binds to all interfaces:",
"localAccessListenNote": "Then enter your machine's LAN IP (e.g. http://192.168.1.x:8188) in the backend URL field above.",
"source": "Source",
"errorUnreachable": "Backend is unreachable. Ensure ComfyUI is running with CORS enabled.",
"errorHttpFailed": "HTTP connection failed. Check the URL and CORS settings.",
"errorWsFailed": "WebSocket connection failed. HTTP works — check firewall or proxy settings.",
"buildPr": "PR #{prNumber}",
"buildVersion": "v{version}",
"tooltipVersion": "Version: {version}",
"tooltipCommit": "Commit: {commit}",
"tooltipBranch": "Branch: {branch}",
"tooltipRunId": "Run ID: {runId}",
"tooltipJobId": "Job ID: {jobId}"
},
"prPreview": {
"badge": {
"label": "PR",
"tooltipBackendLabel": "Backend: ",
"tooltipCloudApiNote": "Cloud API: see Settings → About",
"tooltipCloudApiDisabled": "Cloud API: disabled",
"configureBackend": "Configure backend →"
}
},
"agent": {
"fab": {
"aria": "Open Comfy agent"
},
"input": {
"placeholder": "Ask the agent to do something...",
"send": "Send",
"attach": "Attach file",
"removeAsset": "Remove attachment"
},
"panel": {
"title": "Agent",
"close": "Close",
"clear": "Clear messages",
"empty": "Drop a file, paste an image, or ask anything.",
"stop": "Stop",
"settings": "Open settings",
"dropHint": "Drop to attach",
"resize": "Resize panel",
"brandTitle": "COMFY-AI",
"logoAlt": "Comfy",
"prompt": "comfy>",
"readyHint": "ready. Ask anything, or type a shell command directly.",
"toolFolded": "{count} line | {count} lines, exit {exit}",
"foldAll": "Fold all tool calls (Ctrl+O)",
"unfoldAll": "Expand all tool calls (Ctrl+O)",
"inputPlaceholder": "ask anything, or run a shell command",
"streamingPlaceholder": "(streaming…)"
},
"settings": {
"title": "Agent settings",
"frontendOnly": "Frontend-only agent.",
"frontendOnlyHint": "Runs entirely in your browser. Your API key is stored in localStorage and only sent to the API endpoint you configure — never to ComfyUI's frontend or backend.",
"noKeyWarning": "No API key set yet. Paste an OpenAI or OpenRouter key below to start chatting.",
"advanced": "Advanced",
"baseUrl": "API base URL",
"baseUrlHint": "Leave blank to use the OpenAI default. For OpenRouter use https://openrouter.ai/api/v1; for a local proxy/gateway use its URL.",
"model": "Model",
"modelHint": "Default gpt-5.5 (OpenAI). For OpenRouter try anthropic/claude-3.7-sonnet, google/gemini-2.5-pro, etc.",
"apiKey": "API key",
"apiKeyHint": "Stored only in this browser (localStorage). Overrides VITE_OPENAI_API_KEY. Get a key from",
"apiKeyLinkOpenAI": "OpenAI",
"apiKeyLinkOpenRouter": "OpenRouter",
"apiKeyOr": "or",
"apiKeyOrAny": "or any OpenAI-compatible service, then set the base URL above accordingly.",
"systemPrompt": "Additional system prompt",
"systemPromptHint": "Appended to the built-in system prompt.",
"systemPromptPlaceholder": "Extra instructions for the agent...",
"modelPlaceholder": "gpt-5.5",
"reasoning": "Reasoning effort",
"reasoningHint": "For reasoning-capable models (o1/o3/gpt-5 line). Higher = smarter + slower.",
"reasoningMinimal": "minimal",
"reasoningLow": "low",
"reasoningMedium": "medium",
"reasoningHigh": "high",
"localBridge": "Local agent bridge",
"bridgeConnected": "Daemon connected (ws://127.0.0.1:7437)",
"bridgeDisconnected": "Daemon not running — start with: comfy-ai serve",
"bridgePair": "Generate pair code",
"bridgePairHint": "Run in your terminal:",
"bridgePairWaiting": "Waiting for CLI to claim the code…"
}
}
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isBackendReachable } from './backendReachable'
const STORAGE_KEY = 'comfyui-preview-backend-url'
const mockLocalStorage = vi.hoisted(() => {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) delete store[key]
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
_store: store
}
})
vi.stubGlobal('localStorage', mockLocalStorage)
function mockFetchOnce(impl: () => Promise<Response> | Response) {
vi.stubGlobal('fetch', vi.fn(impl))
}
describe('isBackendReachable', () => {
beforeEach(() => {
mockLocalStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.stubGlobal('localStorage', mockLocalStorage)
})
it('returns true when system_stats responds with a system field', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(
() =>
new Response(JSON.stringify({ system: { os: 'darwin' } }), {
status: 200
})
)
expect(await isBackendReachable()).toBe(true)
})
it('returns false when response is not ok', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => new Response('Not Found', { status: 404 }))
expect(await isBackendReachable()).toBe(false)
})
it('returns false when response is HTML (no system field)', async () => {
// Simulates a Cloudflare-style SPA fallback returning index.html
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => new Response(JSON.stringify({}), { status: 200 }))
expect(await isBackendReachable()).toBe(false)
})
it('returns false when fetch rejects (network error / CORS / aborted)', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => Promise.reject(new Error('network')))
expect(await isBackendReachable()).toBe(false)
})
it('strips trailing slashes from the configured backend URL', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188///')
const fetchSpy = vi.fn(
() =>
new Response(JSON.stringify({ system: { os: 'linux' } }), {
status: 200
})
)
vi.stubGlobal('fetch', fetchSpy)
await isBackendReachable()
expect(fetchSpy).toHaveBeenCalledWith(
'http://127.0.0.1:8188/api/system_stats',
expect.any(Object)
)
})
it('falls back to same-origin when no backend URL is configured', async () => {
const fetchSpy = vi.fn(
() =>
new Response(JSON.stringify({ system: { os: 'linux' } }), {
status: 200
})
)
vi.stubGlobal('fetch', fetchSpy)
await isBackendReachable()
expect(fetchSpy).toHaveBeenCalledWith(
'/api/system_stats',
expect.any(Object)
)
})
})

View File

@@ -0,0 +1,47 @@
/**
* Probe the configured ComfyUI backend (local or remote-via-localStorage)
* to confirm it serves the expected `/api/system_stats` shape. Used by the
* router to decide whether to enter GraphView or redirect to /connect.
*/
const BACKEND_URL_KEY = 'comfyui-preview-backend-url'
const PROBE_TIMEOUT_MS = 3000
function resolveProbeBase(): string {
if (typeof window === 'undefined' || !('localStorage' in window)) return ''
const stored = localStorage.getItem(BACKEND_URL_KEY)
if (stored) {
try {
// Only treat the stored value as a backend override when it's a
// well-formed absolute URL — otherwise fall through to same-origin.
const url = new URL(stored)
return url.origin + url.pathname.replace(/\/+$/, '')
} catch {
// Ignore malformed entries; same-origin probe is safer than a
// relative URL that misses the router's subpath base.
}
}
// Mirror ComfyApi's same-origin base so subpath deployments probe the
// backend that would actually serve the app.
return window.location.pathname.split('/').slice(0, -1).join('/')
}
export async function isBackendReachable(): Promise<boolean> {
const apiBase = resolveProbeBase()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
const res = await fetch(`${apiBase}/api/system_stats`, {
signal: controller.signal
})
if (!res.ok) return false
const body = (await res.json()) as { system?: unknown }
return !!body.system
} catch {
return false
} finally {
clearTimeout(timeout)
}
}

View File

@@ -0,0 +1,24 @@
const COMFY_API_BASE_FLAG = '--comfy-api-base'
const DEFAULT_CLOUD_API_BASE = 'https://api.comfy.org'
type SystemInfo = { argv?: string[]; comfy_api_base?: string }
function parseArgvApiBase(argv: string[] | undefined): string | undefined {
if (!argv) return undefined
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === COMFY_API_BASE_FLAG && i + 1 < argv.length) return argv[i + 1]
if (a.startsWith(`${COMFY_API_BASE_FLAG}=`))
return a.slice(COMFY_API_BASE_FLAG.length + 1)
}
return undefined
}
export function resolveBackendCloudBase(
system: SystemInfo | undefined
): string {
const explicit = system?.comfy_api_base
if (explicit) return explicit.replace(/\/+$/, '')
const fromArgv = parseArgvApiBase(system?.argv)
return (fromArgv ?? DEFAULT_CLOUD_API_BASE).replace(/\/+$/, '')
}

View File

@@ -8,6 +8,7 @@ import {
import type { RouteLocationNormalized } from 'vue-router'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isBackendReachable } from '@/platform/connectionPanel/backendReachable'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -29,13 +30,17 @@ const isFileProtocol = window.location.protocol === 'file:'
* Determine base path for the router.
* - Electron: always root
* - Cloud: use Vite's BASE_URL (configured at build time)
* - Standard web (including reverse proxy subpaths): use window.location.pathname
* to support deployments like http://mysite.com/ComfyUI/
* - Standard web: a deploy directory pathname ends with `/`
* (e.g. `/ComfyUI/`) — use it as base to support reverse-proxy subpaths.
* A SPA route pathname does not end with `/` (e.g. `/connect`) — fall back
* to BASE_URL so the route doesn't get appended to itself.
*/
function getBasePath(): string {
if (isDesktop) return '/'
if (isCloud) return import.meta.env?.BASE_URL || '/'
return window.location.pathname
const pathname = window.location.pathname
if (pathname.endsWith('/')) return pathname
return import.meta.env?.BASE_URL || '/'
}
const basePath = getBasePath()
@@ -66,6 +71,12 @@ const router = createRouter({
name: 'GraphView',
component: () => import('@/views/GraphView.vue'),
beforeEnter: async (_to, _from, next) => {
// Redirect to /connect when no ComfyUI backend is reachable
// (e.g. static deployments like Cloudflare Pages preview)
if (!(await isBackendReachable())) {
return next('/connect')
}
// Then check user store
const userStore = useUserStore()
await userStore.initialize()
@@ -82,6 +93,11 @@ const router = createRouter({
component: () => import('@/views/UserSelectView.vue')
}
]
},
{
path: '/connect',
name: 'ConnectionPanel',
component: () => import('@/views/ConnectionPanelView.vue')
}
],

View File

@@ -247,6 +247,7 @@ const zSystemStats = z.object({
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
comfy_api_base: z.string().optional(),
ram_total: z.number(),
ram_free: z.number(),
// Cloud-specific fields

View File

@@ -367,27 +367,52 @@ export class ComfyApi extends EventTarget {
*/
apiKey?: string
/**
* The origin (protocol + host) for the backend, when overridden via the
* preview connection panel. Empty string means use same-origin.
*/
private remoteOrigin = ''
constructor() {
super()
this.user = ''
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
const remoteBackend = localStorage.getItem('comfyui-preview-backend-url')
let parsedRemote: URL | null = null
if (remoteBackend) {
try {
parsedRemote = new URL(remoteBackend)
} catch {
// Corrupt value would crash the app at startup; drop it and fall back.
localStorage.removeItem('comfyui-preview-backend-url')
}
}
if (parsedRemote) {
this.remoteOrigin = parsedRemote.origin
this.api_host = parsedRemote.host
this.api_base = parsedRemote.pathname.replace(/\/+$/, '')
} else {
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
}
this.initialClientId = sessionStorage.getItem('clientId')
}
internalURL(route: string): string {
return this.api_base + '/internal' + route
return this.remoteOrigin + this.api_base + '/internal' + route
}
apiURL(route: string): string {
if (route.startsWith('/api')) return this.api_base + route
return this.api_base + '/api' + route
if (route.startsWith('/api'))
return this.remoteOrigin + this.api_base + route
return this.remoteOrigin + this.api_base + '/api' + route
}
fileURL(route: string): string {
return this.api_base + route
return this.remoteOrigin + this.api_base + route
}
/**
@@ -578,8 +603,14 @@ export class ComfyApi extends EventTarget {
}
}
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const baseUrl = `${protocol}://${this.api_host}${this.api_base}/ws`
// Derive WebSocket protocol from remote backend if set, else from page
let wsProtocol: string
if (this.remoteOrigin) {
wsProtocol = this.remoteOrigin.startsWith('https:') ? 'wss' : 'ws'
} else {
wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
}
const baseUrl = `${wsProtocol}://${this.api_host}${this.api_base}/ws`
const query = params.toString()
const wsUrl = query ? `${baseUrl}?${query}` : baseUrl

View File

@@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = {
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
],
properties: {},
properties: {
models: [
{
name: 'v1-5-pruned-emaonly-fp16.safetensors',
url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true',
directory: 'checkpoints'
}
]
},
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
}
],

View File

@@ -57,6 +57,12 @@ export interface TopbarBadge {
* Optional tooltip text to show on hover
*/
tooltip?: string
/**
* Optional links rendered as clickable anchors inside the popover.
* External URLs (starting with "http") open in a new tab; internal
* paths (e.g. "/connect") navigate within the SPA.
*/
popoverLinks?: Array<{ label: string; url: string }>
}
/*

View File

@@ -0,0 +1,286 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ConnectionPanelView from './ConnectionPanelView.vue'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({ changeTheme: vi.fn() })),
isNativeWindow: vi.fn(() => false)
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: false,
isCloud: false,
isNightly: false
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn()
})
}))
const mockLocalStorage = vi.hoisted(() => {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) delete store[key]
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
_store: store
}
})
vi.stubGlobal('localStorage', mockLocalStorage)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function renderPanel() {
return render(ConnectionPanelView, {
global: {
plugins: [i18n]
}
})
}
describe('ConnectionPanelView', () => {
beforeEach(() => {
mockLocalStorage.clear()
vi.restoreAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the backend URL input with default value', () => {
renderPanel()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
expect(input).toBeTruthy()
})
it('loads backend URL from localStorage', () => {
mockLocalStorage.setItem(
'comfyui-preview-backend-url',
'http://192.168.1.100:8188'
)
renderPanel()
const input = screen.getByDisplayValue(
'http://192.168.1.100:8188'
) as HTMLInputElement
expect(input).toBeTruthy()
})
it('shows test button', () => {
renderPanel()
expect(screen.getByRole('button', { name: /test/i })).toBeTruthy()
})
it('displays the comfy-cli install command', () => {
renderPanel()
expect(screen.getByText('pip install comfy-cli')).toBeTruthy()
})
it('displays the comfy launch command', () => {
renderPanel()
expect(
screen.getByText(
`comfy launch -- --enable-cors-header="${window.location.origin}"`
)
).toBeTruthy()
})
it('displays the local network access section', () => {
renderPanel()
expect(
screen.getByRole('heading', { level: 2, name: /local/i })
).toBeTruthy()
})
it('saves URL to localStorage on test', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
renderPanel()
const user = userEvent.setup()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
await user.clear(input)
await user.type(input, 'http://10.0.0.1:8188')
const testButton = screen.getByRole('button', { name: /test/i })
await user.click(testButton)
await vi.waitFor(() => {
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'comfyui-preview-backend-url',
'http://10.0.0.1:8188'
)
})
})
it('shows red HTTP indicator when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
// Stub WebSocket to never open so wsStatus also resolves to false
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'error') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
// i18n in tests is empty so the status text falls back to the key
expect(screen.getByText(/connectionPanel\.error/)).toBeTruthy()
})
})
it('normalizes a URL without protocol by prepending http://', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
renderPanel()
const user = userEvent.setup()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
await user.clear(input)
await user.type(input, '192.168.1.50:8188')
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'comfyui-preview-backend-url',
'http://192.168.1.50:8188'
)
})
})
it('links to staging platform when backend uses staging cloud base', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
system: {
argv: [
'main.py',
'--comfy-api-base=https://stagingapi.comfy.org'
]
}
})
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
const link = screen.getByRole('link', {
name: 'connectionPanel.getApiKeyLink'
})
expect(link.getAttribute('href')).toBe(
'https://stagingplatform.comfy.org/profile/api-keys'
)
})
})
it('parses backend cloud API base from system_stats argv', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
system: {
argv: [
'main.py',
'--enable-cors-header=*',
'--comfy-api-base',
'https://stagingapi.comfy.org'
]
}
})
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
expect(screen.getByText('https://stagingapi.comfy.org')).toBeTruthy()
})
})
it('reveals Connect & Open ComfyUI button after a successful HTTP test', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ system: { argv: [] } })
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
// i18n in tests is empty so the button label falls back to the key
expect(screen.getByText('connectionPanel.connectAndGo')).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,598 @@
<template>
<BaseViewTemplate dark>
<main
class="relative my-8 flex w-full max-w-lg flex-col gap-6 rounded-lg bg-(--comfy-menu-bg) p-8 shadow-lg"
>
<header class="flex flex-col gap-2">
<h1 class="text-xl font-semibold text-neutral-100">
{{ t('connectionPanel.title') }}
</h1>
<p class="text-sm text-neutral-400">
{{ t('connectionPanel.subtitle') }}
</p>
<aside
v-if="prNumber"
class="mt-1 flex flex-col gap-1 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200"
>
<p class="font-medium">
{{ t('connectionPanel.previewWarningTitle') }}
</p>
<p class="text-amber-200/85">
{{ t('connectionPanel.previewWarningBody') }}
</p>
<i18n-t
keypath="connectionPanel.previewProvenance"
tag="p"
class="text-amber-200/85"
>
<template #pr>
<a
:href="prUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
>#{{ prNumber }}</a
>
</template>
<template #commit>
<a
:href="commitUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
><code>{{ commitShort }}</code></a
>
</template>
<template #author>
<a
v-if="prAuthor"
:href="authorUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
>@{{ prAuthor }}</a
>
<span v-else>{{
t('connectionPanel.previewUnknownAuthor')
}}</span>
</template>
</i18n-t>
<p class="font-medium text-amber-100">
{{ t('connectionPanel.previewTrustWarning') }}
</p>
</aside>
</header>
<!-- Backend URL input -->
<section class="flex flex-col gap-2">
<label for="backend-url" class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.backendUrl') }}
</label>
<div class="flex gap-2">
<input
id="backend-url"
v-model="backendUrl"
type="url"
:placeholder="DEFAULT_BACKEND_URL"
class="flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-neutral-800 px-4 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
@keyup.enter="testConnection"
/>
<Button
variant="primary"
size="lg"
:loading="isTesting"
:disabled="isTesting"
@click="testConnection"
>
{{ t('connectionPanel.test') }}
</Button>
</div>
</section>
<!-- Connection status -->
<section
v-if="httpStatus !== null || wsStatus !== null"
role="status"
aria-live="polite"
class="flex flex-col gap-2 rounded-md bg-neutral-800/50 p-3"
>
<h2
class="text-xs font-medium tracking-wide text-neutral-400 uppercase"
>
{{ t('connectionPanel.status') }}
</h2>
<div class="flex gap-4 text-sm">
<span class="flex items-center gap-1.5">
<span
:class="
cn(
'inline-block size-2 rounded-full',
httpStatus === true && 'bg-green-500',
httpStatus === false && 'bg-red-500',
httpStatus === null && 'bg-neutral-600'
)
"
/>
{{ t('connectionPanel.http') }}
{{ httpStatus === true ? '✓' : httpStatus === false ? '✗' : '—' }}
</span>
<span class="flex items-center gap-1.5">
<span
:class="
cn(
'inline-block size-2 rounded-full',
wsStatus === true && 'bg-green-500',
wsStatus === false && 'bg-red-500',
wsStatus === null && 'bg-neutral-600'
)
"
/>
{{ t('connectionPanel.ws') }}
{{ wsStatus === true ? '✓' : wsStatus === false ? '✗' : '—' }}
</span>
</div>
<p v-if="connectionError" class="text-xs text-red-400">
{{ connectionError }}
</p>
<p
v-if="httpStatus === true && wsStatus === true"
class="text-xs text-green-400"
>
{{ t('connectionPanel.connected') }}
</p>
<!-- Backend cloud-API base + API key -->
<div
v-if="backendCloudBase"
class="flex flex-col gap-3 border-t border-neutral-700 pt-2"
>
<p class="text-xs text-neutral-400">
<span class="text-neutral-500"
>{{ t('connectionPanel.backendCloud') }}
</span>
<code
class="ml-1 rounded-sm bg-neutral-900 px-1 py-0.5 text-neutral-200"
>{{ backendCloudBase }}</code
>
</p>
<p v-if="cloudMismatch" class="text-xs text-amber-400">
{{
t('connectionPanel.cloudMismatch', {
frontend: frontendCloudBase
})
}}
</p>
<!-- API key input hidden when --disable-api-nodes -->
<div v-if="!isApiNodeDisabled" class="flex flex-col gap-1.5">
<label for="api-key" class="text-xs font-medium text-neutral-300">
{{ t('connectionPanel.apiKey') }}
<span class="ml-1 font-normal text-neutral-500">{{
t('connectionPanel.apiKeyOptional')
}}</span>
</label>
<div class="flex gap-2">
<input
id="api-key"
v-model="apiKeyInput"
type="password"
:placeholder="t('connectionPanel.apiKeyPlaceholder')"
autocomplete="current-password"
class="flex h-8 w-full min-w-0 appearance-none rounded-md border-none bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
@keyup.enter="testApiKey"
/>
<Button
variant="secondary"
size="sm"
:loading="isTestingApiKey"
:disabled="isTestingApiKey || !apiKeyInput.trim()"
@click="testApiKey"
>
{{ t('connectionPanel.test') }}
</Button>
</div>
<p v-if="apiKeyStatus === 'ok'" class="text-xs text-green-400">
{{ t('connectionPanel.apiKeyTestOk') }}
</p>
<p
v-else-if="apiKeyStatus === 'error'"
class="text-xs text-red-400"
>
{{ t('connectionPanel.apiKeyTestError') }}
</p>
<p v-else class="text-xs text-neutral-500">
{{ t('connectionPanel.apiKeyHint') }}
<a
v-if="apiKeyPageUrl"
:href="apiKeyPageUrl"
target="_blank"
rel="noopener"
class="ml-1 text-neutral-400 underline decoration-dotted hover:text-neutral-200"
>
{{ t('connectionPanel.getApiKeyLink') }}
</a>
</p>
</div>
<p v-else class="text-xs text-neutral-500">
{{ t('connectionPanel.apiKeyDisabledNotice') }}
</p>
</div>
<!-- Connect & Go button -->
<Button
v-if="httpStatus === true"
variant="primary"
size="lg"
class="mt-2 w-full"
@click="connectAndGo"
>
{{ t('connectionPanel.connectAndGo') }}
</Button>
</section>
<!-- Quick Start with Comfy CLI -->
<section class="flex flex-col gap-3">
<h2 class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.quickStart') }}
</h2>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.quickStartDescription') }}
</p>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step1InstallUv') }}
</span>
<CopyCodeBlock
text="curl -LsSf https://astral.sh/uv/install.sh | sh"
/>
<CopyCodeBlock
text='powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
/>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.uvNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step2InstallComfyui') }}
</span>
<CopyCodeBlock
text="uv pip install comfy-cli --system && comfy install"
/>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.managerIncludedNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step3Launch') }}
</span>
<CopyCodeBlock :text="launchCmd" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.corsOriginNote') }}
</p>
</div>
</div>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.corsNote') }}
</p>
<aside
class="flex flex-col gap-1 rounded-md border border-neutral-700 bg-neutral-800/50 p-3"
>
<h3 class="text-xs font-medium text-neutral-300">
{{ t('connectionPanel.managerTitle') }}
</h3>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.managerDescription') }}
</p>
<a
href="https://github.com/Comfy-Org/ComfyUI-Manager"
target="_blank"
rel="noopener"
class="text-xs text-neutral-300 underline hover:text-neutral-100"
>
{{ t('connectionPanel.managerLearnMore') }}
</a>
</aside>
</section>
<!-- Alternative: manual python / pip -->
<details class="group">
<summary
class="cursor-pointer text-sm font-medium text-neutral-400 hover:text-neutral-300"
>
{{ t('connectionPanel.altManualSetup') }}
</summary>
<div class="mt-2 flex flex-col gap-3">
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.altPipDescription') }}
</p>
<CopyCodeBlock text="pip install comfy-cli" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.altPipNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.altManagerDescription') }}
</p>
<CopyCodeBlock
text="git clone https://github.com/Comfy-Org/ComfyUI-Manager.git custom_nodes/ComfyUI-Manager"
/>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.guideDescription') }}
</p>
<CopyCodeBlock :text="pythonMainCmd" />
</div>
</div>
</details>
<!-- Local network access -->
<section class="flex flex-col gap-3">
<h2 class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.localAccess') }}
</h2>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.localAccessDescription') }}
</p>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.localAccessListenDescription') }}
</p>
<CopyCodeBlock :text="launchListenCmd" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.localAccessListenNote') }}
</p>
</div>
</section>
<footer
class="flex items-center justify-between border-t border-neutral-700 pt-4 text-xs text-neutral-500"
>
<span
:title="buildTooltip"
class="cursor-help underline decoration-dotted"
>
{{ buildLabel }}
</span>
<a
:href="repoUrl"
target="_blank"
rel="noopener"
class="text-neutral-400 hover:text-neutral-200"
>
{{ t('connectionPanel.source') }}
</a>
</footer>
</main>
</BaseViewTemplate>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import CopyCodeBlock from '@/components/connection/CopyCodeBlock.vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
getComfyApiBaseUrl,
getPlatformBaseUrlForApiBase
} from '@/config/comfyApi'
import { resolveBackendCloudBase } from '@/platform/connectionPanel/resolveBackendCloudBase'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
type SystemStats = {
system?: { argv?: string[]; comfy_api_base?: string }
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '')
}
const { t } = useI18n()
const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8188'
const STORAGE_KEY = 'comfyui-preview-backend-url'
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
const corsOrigin = window.location.origin
const backendUrl = ref(localStorage.getItem(STORAGE_KEY) || DEFAULT_BACKEND_URL)
const API_KEY_STORAGE_KEY = 'comfy_api_key'
const apiKeyInput = ref(localStorage.getItem(API_KEY_STORAGE_KEY) ?? '')
const launchCmd = `comfy launch -- --enable-cors-header="${corsOrigin}"`
const launchListenCmd = `comfy launch -- --listen --enable-cors-header="${corsOrigin}"`
const pythonMainCmd = `python main.py --enable-cors-header="${corsOrigin}"`
const isTesting = ref(false)
const httpStatus = ref<boolean | null>(null)
const wsStatus = ref<boolean | null>(null)
const connectionError = ref('')
const backendCloudBase = ref<string | null>(null)
const isApiNodeDisabled = ref(false)
const isTestingApiKey = ref(false)
const apiKeyStatus = ref<'idle' | 'ok' | 'error'>('idle')
const frontendCloudBase = stripTrailingSlash(getComfyApiBaseUrl())
const cloudMismatch = computed(
() =>
backendCloudBase.value !== null &&
backendCloudBase.value !== frontendCloudBase
)
const apiKeyPageUrl = computed(() => {
if (!backendCloudBase.value) return null
const platform = getPlatformBaseUrlForApiBase(backendCloudBase.value)
return platform ? `${platform}/profile/api-keys` : null
})
function normalizeUrl(raw: string): string {
let url = raw.trim()
if (!url) url = DEFAULT_BACKEND_URL
if (!/^https?:\/\//i.test(url)) url = 'http://' + url
return url.replace(/\/+$/, '')
}
async function fetchSystemStats(base: string): Promise<SystemStats | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch(`${base}/api/system_stats`, {
signal: controller.signal
})
if (!res.ok) return null
return (await res.json()) as SystemStats
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
function testWs(base: string): Promise<boolean> {
return new Promise((resolve) => {
const wsUrl = new URL(base)
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl.pathname = '/ws'
wsUrl.search = ''
wsUrl.hash = ''
const ws = new WebSocket(wsUrl.toString())
const timeout = setTimeout(() => {
ws.close()
resolve(false)
}, 5000)
ws.addEventListener('open', () => {
clearTimeout(timeout)
ws.close()
resolve(true)
})
ws.addEventListener('error', () => {
clearTimeout(timeout)
resolve(false)
})
})
}
async function testConnection() {
isTesting.value = true
httpStatus.value = null
wsStatus.value = null
connectionError.value = ''
backendCloudBase.value = null
const base = normalizeUrl(backendUrl.value)
backendUrl.value = base
localStorage.setItem(STORAGE_KEY, base)
try {
const [stats, ws] = await Promise.all([
fetchSystemStats(base),
testWs(base)
])
httpStatus.value = stats !== null
wsStatus.value = ws
backendCloudBase.value = stats
? resolveBackendCloudBase(stats.system)
: null
isApiNodeDisabled.value =
stats?.system?.argv?.includes('--disable-api-nodes') ?? false
if (stats === null && !ws) {
connectionError.value = t('connectionPanel.errorUnreachable')
} else if (stats === null) {
connectionError.value = t('connectionPanel.errorHttpFailed')
} else if (!ws) {
connectionError.value = t('connectionPanel.errorWsFailed')
}
} catch {
httpStatus.value = false
wsStatus.value = false
connectionError.value = t('connectionPanel.errorUnreachable')
} finally {
isTesting.value = false
}
}
async function testApiKey() {
const key = apiKeyInput.value.trim()
if (!key) return
isTestingApiKey.value = true
apiKeyStatus.value = 'idle'
const base = backendCloudBase.value ?? frontendCloudBase
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
try {
const res = await fetch(`${base}/customers`, {
method: 'POST',
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
signal: controller.signal
})
apiKeyStatus.value = res.ok ? 'ok' : 'error'
} catch {
apiKeyStatus.value = 'error'
} finally {
clearTimeout(timeout)
isTestingApiKey.value = false
}
}
function connectAndGo() {
const base = normalizeUrl(backendUrl.value)
localStorage.setItem(STORAGE_KEY, base)
const trimmedKey = apiKeyInput.value.trim()
if (trimmedKey) {
localStorage.setItem(API_KEY_STORAGE_KEY, trimmedKey)
}
// Full page reload so ComfyApi constructor picks up the new backend URL
window.location.href = import.meta.env.BASE_URL || '/'
}
const version = __COMFYUI_FRONTEND_VERSION__
const commit = __COMFYUI_FRONTEND_COMMIT__
const branch = __CI_BRANCH__
const prNumber = __CI_PR_NUMBER__
const prAuthor = __CI_PR_AUTHOR__
const runId = __CI_RUN_ID__
const jobId = __CI_JOB_ID__
const commitShort = commit ? commit.slice(0, 8) : ''
const prUrl = prNumber ? `${REPO}/pull/${prNumber}` : REPO
const commitUrl = commit ? `${REPO}/commit/${commit}` : REPO
const authorUrl = prAuthor ? `https://github.com/${prAuthor}` : ''
const buildLabel = computed(() => {
if (prNumber) return t('connectionPanel.buildPr', { prNumber })
if (branch) return branch
return t('connectionPanel.buildVersion', { version })
})
const buildTooltip = computed(() => {
const parts = [t('connectionPanel.tooltipVersion', { version })]
if (commit)
parts.push(
t('connectionPanel.tooltipCommit', { commit: commit.slice(0, 8) })
)
if (branch) parts.push(t('connectionPanel.tooltipBranch', { branch }))
if (runId) parts.push(t('connectionPanel.tooltipRunId', { runId }))
if (jobId) parts.push(t('connectionPanel.tooltipJobId', { jobId }))
return parts.join('\n')
})
const repoUrl = computed(() => {
if (prNumber) return `${REPO}/pull/${prNumber}`
if (branch) return `${REPO}/tree/${branch}`
return REPO
})
onMounted(() => {
document.getElementById('splash-loader')?.remove()
})
</script>

View File

@@ -13,7 +13,7 @@
ref="topMenuRef"
class="app-drag h-(--comfy-topbar-height) w-full"
/>
<div class="flex w-full grow items-center justify-center overflow-auto">
<div class="grid w-full grow place-items-center overflow-auto">
<slot />
</div>
</div>

7
src/vite-env.d.ts vendored
View File

@@ -17,6 +17,13 @@ declare global {
__COMFYUI_FRONTEND_VERSION__: string
}
const __COMFYUI_FRONTEND_COMMIT__: string
const __CI_BRANCH__: string
const __CI_PR_NUMBER__: string
const __CI_PR_AUTHOR__: string
const __CI_RUN_ID__: string
const __CI_JOB_ID__: string
interface ImportMetaEnv {
VITE_APP_VERSION?: string
}

View File

@@ -17,7 +17,7 @@ import type { ProxyOptions } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import { comfyAPIPlugin } from './build/plugins'
import { agentLogPlugin, comfyAPIPlugin } from './build/plugins'
dotenvConfig()
@@ -260,6 +260,7 @@ export default defineConfig({
tailwindcss(),
typegpuPlugin({}),
comfyAPIPlugin(IS_DEV),
agentLogPlugin(),
// Exclude proprietary ABCROM fonts from non-cloud builds
{
name: 'exclude-proprietary-fonts',
@@ -626,7 +627,12 @@ export default defineConfig({
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY),
__CI_BRANCH__: JSON.stringify(process.env.CI_BRANCH || ''),
__CI_PR_NUMBER__: JSON.stringify(process.env.CI_PR_NUMBER || ''),
__CI_PR_AUTHOR__: JSON.stringify(process.env.CI_PR_AUTHOR || ''),
__CI_RUN_ID__: JSON.stringify(process.env.CI_RUN_ID || ''),
__CI_JOB_ID__: JSON.stringify(process.env.CI_JOB_ID || '')
},
resolve: {