Files
ComfyUI_frontend/src/agent/composables/useAssetIngest.ts
snomiao 87b3f13f87 feat(agent): experimental in-browser ComfyAI agent
Squashes 60 incremental agent commits into one cohesive change after
prior history accumulated 119 unrelated upstream commits during a
bad rebase. Working tree restored from sno-agent@aec9f9380; new deps
added freshly (no stale lockfile entries); changeTracker API call
sites updated for current sno-frontend-preview (captureCanvasState →
checkState).

What's in this commit:

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

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

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

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

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

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

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

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

PR #11547 — experimental, draft, expect breaking changes.
Preview: https://pr-11547.comfy-ui.pages.dev/
2026-05-02 17:39:21 +09:00

93 lines
2.6 KiB
TypeScript

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
}
}