mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
61 Commits
austin/nod
...
sno-agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e5317e79 | ||
|
|
8c6fdeae37 | ||
|
|
270ab4ffcb | ||
|
|
1feebde790 | ||
|
|
68b477dbbc | ||
|
|
46f4ee9e9f | ||
|
|
775f4b28ba | ||
|
|
edd2c3a248 | ||
|
|
5afcb892b8 | ||
|
|
87b3f13f87 | ||
|
|
eba055befe | ||
|
|
4c8e5ad797 | ||
|
|
d3bd6f9f12 | ||
|
|
a785f72aa0 | ||
|
|
6130880992 | ||
|
|
fb46996002 | ||
|
|
bf11a90cd8 | ||
|
|
0c4861162d | ||
|
|
17f5bde180 | ||
|
|
d840020427 | ||
|
|
1a993fc1dc | ||
|
|
321c32463e | ||
|
|
0a441ab896 | ||
|
|
cf01213235 | ||
|
|
61bcf0a8bc | ||
|
|
0b90645d87 | ||
|
|
8f60294f63 | ||
|
|
da8be4dc6c | ||
|
|
3fa9c4522a | ||
|
|
2b675d6b5c | ||
|
|
66072fc4a6 | ||
|
|
5f612e19b2 | ||
|
|
eb1fe9d88a | ||
|
|
51e77c65ad | ||
|
|
324d20477e | ||
|
|
6fb9915b45 | ||
|
|
fed451edac | ||
|
|
3ee55dfa1e | ||
|
|
93073cc242 | ||
|
|
5ef07f09fa | ||
|
|
2d5d77f7db | ||
|
|
ef4d8622fa | ||
|
|
dc2d8375fd | ||
|
|
bd0a10d7f0 | ||
|
|
5b75bb5bbf | ||
|
|
8bdc850830 | ||
|
|
95d0f22906 | ||
|
|
a53ea4dae2 | ||
|
|
7ba5fcdf1a | ||
|
|
c176723bbc | ||
|
|
559001341a | ||
|
|
b9c677f54c | ||
|
|
6304a60656 | ||
|
|
0b0a1076f4 | ||
|
|
d1968f2033 | ||
|
|
f129dc2aeb | ||
|
|
1a4b766fb7 | ||
|
|
3e8022c0d1 | ||
|
|
cb696d8426 | ||
|
|
3720ba3829 | ||
|
|
cdc834705a |
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal 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
3
.gitignore
vendored
@@ -99,4 +99,5 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
162
browser_tests/tests/agentTerminal.spec.ts
Normal file
162
browser_tests/tests/agentTerminal.spec.ts
Normal 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
90
build/plugins/agentLog.ts
Normal 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))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { agentLogPlugin } from './agentLog'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
|
||||
164
docs/adr/0009-frontend-only-agent-and-local-agent-bridge.md
Normal file
164
docs/adr/0009-frontend-only-agent-and-local-agent-bridge.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
78
docs/backend-cloud-api-base-feature-flag.md
Normal file
78
docs/backend-cloud-api-base-feature-flag.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 案 B: バックエンドの `/features` に `comfy_api_base` を追加する
|
||||
|
||||
## 背景
|
||||
|
||||
ComfyUI バックエンドは `--comfy-api-base` CLI フラグで Comfy Cloud の API ベース URL(prod / 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` 既存利用
|
||||
@@ -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
5
global.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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
249
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
209
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable file
209
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable 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
|
||||
@@ -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'
|
||||
|
||||
265
src/agent/composables/useAgentSession.ts
Normal file
265
src/agent/composables/useAgentSession.ts
Normal 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
|
||||
}
|
||||
}
|
||||
62
src/agent/composables/useAssetIngest.test.ts
Normal file
62
src/agent/composables/useAssetIngest.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
92
src/agent/composables/useAssetIngest.ts
Normal file
92
src/agent/composables/useAssetIngest.ts
Normal 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
|
||||
}
|
||||
}
|
||||
70
src/agent/composables/useImageNodeDrop.ts
Normal file
70
src/agent/composables/useImageNodeDrop.ts
Normal 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
|
||||
}
|
||||
236
src/agent/composables/useLocalBridge.ts
Normal file
236
src/agent/composables/useLocalBridge.ts
Normal 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
1091
src/agent/llm/session.ts
Normal file
File diff suppressed because it is too large
Load Diff
79
src/agent/services/logger.ts
Normal file
79
src/agent/services/logger.ts
Normal 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' })
|
||||
}
|
||||
210
src/agent/shell/commands/browser.ts
Normal file
210
src/agent/shell/commands/browser.ts
Normal 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)
|
||||
}
|
||||
171
src/agent/shell/commands/codesearch.ts
Normal file
171
src/agent/shell/commands/codesearch.ts
Normal 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)
|
||||
}
|
||||
60
src/agent/shell/commands/comfy.ts
Normal file
60
src/agent/shell/commands/comfy.ts
Normal 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)
|
||||
}
|
||||
222
src/agent/shell/commands/comfyNs.ts
Normal file
222
src/agent/shell/commands/comfyNs.ts
Normal 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)
|
||||
}
|
||||
131
src/agent/shell/commands/coreutils.test.ts
Normal file
131
src/agent/shell/commands/coreutils.test.ts
Normal 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()
|
||||
)
|
||||
})
|
||||
})
|
||||
184
src/agent/shell/commands/coreutils.ts
Normal file
184
src/agent/shell/commands/coreutils.ts
Normal 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>>
|
||||
145
src/agent/shell/commands/execution.test.ts
Normal file
145
src/agent/shell/commands/execution.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
120
src/agent/shell/commands/execution.ts
Normal file
120
src/agent/shell/commands/execution.ts
Normal 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)
|
||||
}
|
||||
137
src/agent/shell/commands/graph.test.ts
Normal file
137
src/agent/shell/commands/graph.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
197
src/agent/shell/commands/graph.ts
Normal file
197
src/agent/shell/commands/graph.ts
Normal 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)
|
||||
}
|
||||
140
src/agent/shell/commands/images.ts
Normal file
140
src/agent/shell/commands/images.ts
Normal 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)
|
||||
}
|
||||
277
src/agent/shell/commands/install.ts
Normal file
277
src/agent/shell/commands/install.ts
Normal 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)
|
||||
}
|
||||
325
src/agent/shell/commands/layout.ts
Normal file
325
src/agent/shell/commands/layout.ts
Normal 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)
|
||||
}
|
||||
858
src/agent/shell/commands/nodeOps.ts
Normal file
858
src/agent/shell/commands/nodeOps.ts
Normal 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)
|
||||
}
|
||||
176
src/agent/shell/commands/registrySearch.ts
Normal file
176
src/agent/shell/commands/registrySearch.ts
Normal 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)
|
||||
}
|
||||
175
src/agent/shell/commands/see.ts
Normal file
175
src/agent/shell/commands/see.ts
Normal 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)
|
||||
}
|
||||
156
src/agent/shell/commands/state.test.ts
Normal file
156
src/agent/shell/commands/state.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
117
src/agent/shell/commands/state.ts
Normal file
117
src/agent/shell/commands/state.ts
Normal 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)
|
||||
}
|
||||
107
src/agent/shell/commands/sweep.ts
Normal file
107
src/agent/shell/commands/sweep.ts
Normal 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
|
||||
}))
|
||||
}
|
||||
177
src/agent/shell/commands/templates.ts
Normal file
177
src/agent/shell/commands/templates.ts
Normal 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)
|
||||
}
|
||||
172
src/agent/shell/commands/validate.ts
Normal file
172
src/agent/shell/commands/validate.ts
Normal 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)
|
||||
}
|
||||
288
src/agent/shell/commands/workflow.ts
Normal file
288
src/agent/shell/commands/workflow.ts
Normal 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)
|
||||
}
|
||||
176
src/agent/shell/integration.test.ts
Normal file
176
src/agent/shell/integration.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
96
src/agent/shell/parser.test.ts
Normal file
96
src/agent/shell/parser.test.ts
Normal 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
132
src/agent/shell/parser.ts
Normal 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 }
|
||||
}
|
||||
151
src/agent/shell/runtime.test.ts
Normal file
151
src/agent/shell/runtime.test.ts
Normal 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
214
src/agent/shell/runtime.ts
Normal 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
84
src/agent/shell/types.ts
Normal 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
|
||||
}
|
||||
73
src/agent/shell/vfs/memory.test.ts
Normal file
73
src/agent/shell/vfs/memory.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
86
src/agent/shell/vfs/memory.ts
Normal file
86
src/agent/shell/vfs/memory.ts
Normal 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
|
||||
}
|
||||
}
|
||||
69
src/agent/shell/vfs/mount.test.ts
Normal file
69
src/agent/shell/vfs/mount.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
124
src/agent/shell/vfs/mount.ts
Normal file
124
src/agent/shell/vfs/mount.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/agent/shell/vfs/userdata.test.ts
Normal file
101
src/agent/shell/vfs/userdata.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
105
src/agent/shell/vfs/userdata.ts
Normal file
105
src/agent/shell/vfs/userdata.ts
Normal 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
|
||||
}
|
||||
}
|
||||
99
src/agent/stores/agentStore.test.ts
Normal file
99
src/agent/stores/agentStore.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
175
src/agent/stores/agentStore.ts
Normal file
175
src/agent/stores/agentStore.ts
Normal 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
113
src/agent/ui/AgentFab.vue
Normal 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>
|
||||
54
src/agent/ui/AgentRoot.vue
Normal file
54
src/agent/ui/AgentRoot.vue
Normal 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>
|
||||
216
src/agent/ui/AgentSettings.vue
Normal file
216
src/agent/ui/AgentSettings.vue
Normal 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>
|
||||
744
src/agent/ui/FoldablePanel.vue
Normal file
744
src/agent/ui/FoldablePanel.vue
Normal 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">> </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>
|
||||
33
src/components/connection/CopyCodeBlock.vue
Normal file
33
src/components/connection/CopyCodeBlock.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -52,3 +52,8 @@ if (isCloud || isNightly) {
|
||||
if (isNightly && !isCloud) {
|
||||
await import('./nightlyBadges')
|
||||
}
|
||||
|
||||
// PR preview build badge
|
||||
if (__CI_PR_NUMBER__) {
|
||||
await import('./prPreviewBadges')
|
||||
}
|
||||
|
||||
82
src/extensions/core/prPreviewBadges.ts
Normal file
82
src/extensions/core/prPreviewBadges.ts
Normal 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
|
||||
})
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
src/platform/connectionPanel/backendReachable.test.ts
Normal file
112
src/platform/connectionPanel/backendReachable.test.ts
Normal 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
47
src/platform/connectionPanel/backendReachable.ts
Normal file
47
src/platform/connectionPanel/backendReachable.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
24
src/platform/connectionPanel/resolveBackendCloudBase.ts
Normal file
24
src/platform/connectionPanel/resolveBackendCloudBase.ts
Normal 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(/\/+$/, '')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
286
src/views/ConnectionPanelView.test.ts
Normal file
286
src/views/ConnectionPanelView.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
598
src/views/ConnectionPanelView.vue
Normal file
598
src/views/ConnectionPanelView.vue
Normal 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>
|
||||
@@ -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
7
src/vite-env.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user