mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 22:21:51 +00:00
Compare commits
61 Commits
refactor/e
...
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
|
||||
4
.github/workflows/ci-website-e2e.yaml
vendored
4
.github/workflows/ci-website-e2e.yaml
vendored
@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
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
|
||||
|
||||
@@ -4,12 +4,12 @@ const translations = {
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
'zh-CN': '视觉 AI 的\n最强可控性'
|
||||
'zh-CN': '视觉 AI 的\n专业控制'
|
||||
},
|
||||
'hero.subtitle': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
@@ -20,11 +20,11 @@ const translations = {
|
||||
},
|
||||
'showcase.subtitle2': {
|
||||
en: 'Start from a community template or build from scratch.',
|
||||
'zh-CN': '从工作流模板开始,或从零构建。'
|
||||
'zh-CN': '从社区模板开始,或从零构建。'
|
||||
},
|
||||
'showcase.feature1.title': {
|
||||
en: 'Full Control with Nodes',
|
||||
'zh-CN': '节点带来的可控性'
|
||||
'zh-CN': '节点式完全控制'
|
||||
},
|
||||
'showcase.feature1.description': {
|
||||
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
|
||||
@@ -49,8 +49,8 @@ const translations = {
|
||||
'zh-CN':
|
||||
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
|
||||
},
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运作' },
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.label': {
|
||||
@@ -83,7 +83,8 @@ const translations = {
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
|
||||
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
@@ -163,7 +164,7 @@ const translations = {
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
'zh-CN': '查看本地版特性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -175,7 +176,7 @@ const translations = {
|
||||
},
|
||||
'products.cloud.cta': {
|
||||
en: 'SEE CLOUD FEATURES',
|
||||
'zh-CN': '查看云端属性'
|
||||
'zh-CN': '查看云端特性'
|
||||
},
|
||||
'products.api.title': {
|
||||
en: 'Comfy\nAPI',
|
||||
@@ -187,7 +188,7 @@ const translations = {
|
||||
},
|
||||
'products.api.cta': {
|
||||
en: 'SEE API FEATURES',
|
||||
'zh-CN': '查看 API 属性'
|
||||
'zh-CN': '查看 API 特性'
|
||||
},
|
||||
'products.enterprise.title': {
|
||||
en: 'Comfy\nEnterprise',
|
||||
@@ -199,7 +200,7 @@ const translations = {
|
||||
},
|
||||
'products.enterprise.cta': {
|
||||
en: 'SEE ENTERPRISE FEATURES',
|
||||
'zh-CN': '查看企业版属性'
|
||||
'zh-CN': '查看企业版特性'
|
||||
},
|
||||
|
||||
// CaseStudySpotlightSection
|
||||
@@ -1214,7 +1215,7 @@ const translations = {
|
||||
'pricing.included.feature4.description': {
|
||||
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
|
||||
'zh-CN':
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro)。'
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro)。'
|
||||
},
|
||||
'pricing.included.feature5.title': {
|
||||
en: 'Add more credits anytime',
|
||||
@@ -1244,12 +1245,12 @@ const translations = {
|
||||
},
|
||||
'pricing.included.feature8.title': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
'zh-CN': '合作节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
|
||||
@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -51,7 +51,6 @@ DISABLE_VUE_PLUGINS=true
|
||||
# Test against dev server (recommended) or backend directly
|
||||
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
|
||||
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
|
||||
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
|
||||
|
||||
# Path to ComfyUI for backing up user data/settings before tests
|
||||
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [450, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -137,7 +137,6 @@ class ComfyMenu {
|
||||
|
||||
export class ComfyPage {
|
||||
public readonly url: string
|
||||
public readonly apiUrl: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
public readonly selectionToolbox: Locator
|
||||
@@ -196,7 +195,6 @@ export class ComfyPage {
|
||||
public readonly request: APIRequestContext
|
||||
) {
|
||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
|
||||
this.canvas = page.locator('#graph-canvas')
|
||||
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
@@ -238,7 +236,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.apiUrl}/api/users`)
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
@@ -252,7 +250,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
@@ -264,7 +262,7 @@ export class ComfyPage {
|
||||
|
||||
async setupSettings(settings: Record<string, unknown>) {
|
||||
const resp = await this.request.post(
|
||||
`${this.apiUrl}/api/devtools/set_settings`,
|
||||
`${this.url}/api/devtools/set_settings`,
|
||||
{
|
||||
data: settings
|
||||
}
|
||||
@@ -520,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()
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
readonly filterSearch: Locator
|
||||
readonly results: Locator
|
||||
readonly filterOptions: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.dialog = page.getByRole('search')
|
||||
this.input = this.dialog.getByRole('combobox')
|
||||
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
|
||||
this.input = this.dialog.locator('input[type="text"]')
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class PublishDialog extends BaseDialog {
|
||||
readonly nav: Locator
|
||||
readonly footer: Locator
|
||||
readonly savePrompt: Locator
|
||||
readonly describeStep: Locator
|
||||
readonly finishStep: Locator
|
||||
readonly profilePrompt: Locator
|
||||
readonly gateFlow: Locator
|
||||
readonly nameInput: Locator
|
||||
readonly descriptionTextarea: Locator
|
||||
readonly tagsInput: Locator
|
||||
readonly backButton: Locator
|
||||
readonly nextButton: Locator
|
||||
readonly publishButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.publish.dialog)
|
||||
this.nav = this.root.getByTestId(TestIds.publish.nav)
|
||||
this.footer = this.root.getByTestId(TestIds.publish.footer)
|
||||
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
|
||||
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
|
||||
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
|
||||
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
|
||||
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
|
||||
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
|
||||
this.descriptionTextarea = this.describeStep.locator('textarea')
|
||||
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
|
||||
this.backButton = this.footer.getByRole('button', { name: 'Back' })
|
||||
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
|
||||
this.publishButton = this.footer.getByRole('button', {
|
||||
name: 'Publish to ComfyHub'
|
||||
})
|
||||
}
|
||||
|
||||
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
|
||||
// dev and production, rather than clicking through the UI.
|
||||
async open(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.dialog.showPublishDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
tagSuggestion(name: string): Locator {
|
||||
return this.describeStep.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
navStep(label: string): Locator {
|
||||
return this.nav.getByRole('button', { name: label })
|
||||
}
|
||||
|
||||
currentNavStep(): Locator {
|
||||
return this.nav.locator('[aria-current="step"]')
|
||||
}
|
||||
|
||||
async goNext(): Promise<void> {
|
||||
await this.nextButton.click()
|
||||
}
|
||||
|
||||
async goBack(): Promise<void> {
|
||||
await this.backButton.click()
|
||||
}
|
||||
|
||||
async goToStep(label: string): Promise<void> {
|
||||
await this.navStep(label).click()
|
||||
}
|
||||
}
|
||||
@@ -250,26 +250,6 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
|
||||
type MediaFilterLabel = 'Image' | 'Video' | 'Audio' | '3D'
|
||||
|
||||
function getMediaFilterLabel(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): MediaFilterLabel {
|
||||
switch (filter) {
|
||||
case 'image':
|
||||
return 'Image'
|
||||
case 'video':
|
||||
return 'Video'
|
||||
case 'audio':
|
||||
return 'Audio'
|
||||
case '3d':
|
||||
return '3D'
|
||||
default:
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Tab navigation ---
|
||||
public readonly generatedTab: Locator
|
||||
@@ -283,12 +263,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
public readonly settingsButton: Locator
|
||||
public readonly filterButton: Locator
|
||||
|
||||
// --- Filter menu checkboxes (cloud-only, shown inside filter popover) ---
|
||||
public readonly filterImageCheckbox: Locator
|
||||
public readonly filterVideoCheckbox: Locator
|
||||
public readonly filterAudioCheckbox: Locator
|
||||
public readonly filter3DCheckbox: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
public readonly gridViewOption: Locator
|
||||
@@ -296,8 +270,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
public readonly sortLongestFirst: Locator
|
||||
public readonly sortFastestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
@@ -329,16 +301,10 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.filterButton = page.getByRole('button', { name: 'Filter by' })
|
||||
this.filterImageCheckbox = page.getByRole('checkbox', { name: 'Image' })
|
||||
this.filterVideoCheckbox = page.getByRole('checkbox', { name: 'Video' })
|
||||
this.filterAudioCheckbox = page.getByRole('checkbox', { name: 'Audio' })
|
||||
this.filter3DCheckbox = page.getByRole('checkbox', { name: '3D' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.sortLongestFirst = page.getByText('Generation time (longest first)')
|
||||
this.sortFastestFirst = page.getByText('Generation time (fastest first)')
|
||||
this.assetCards = page
|
||||
.getByRole('button')
|
||||
.and(page.locator('[data-selected]'))
|
||||
@@ -370,10 +336,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
|
||||
return this.page.getByRole('checkbox', {
|
||||
name: getMediaFilterLabel(filter)
|
||||
})
|
||||
filterCheckbox(label: string) {
|
||||
return this.page.getByRole('checkbox', { name: label })
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
@@ -428,26 +392,13 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
async openFilterMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.filterButton.click()
|
||||
// Wait for popover content with checkboxes to render
|
||||
await this.filterCheckbox('Image').waitFor({
|
||||
state: 'visible',
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async toggleMediaTypeFilter(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): Promise<void> {
|
||||
const checkbox = this.filterCheckbox(filter)
|
||||
const before = await checkbox.getAttribute('aria-checked')
|
||||
await checkbox.click()
|
||||
const expected = before === 'true' ? 'false' : 'true'
|
||||
await expect(checkbox).toHaveAttribute('aria-checked', expected)
|
||||
}
|
||||
|
||||
async getAssetCardOrder(): Promise<string[]> {
|
||||
return await this.assetCards.allInnerTexts()
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
|
||||
@@ -1,72 +1,32 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportResponse,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = '**/api/jobs?*'
|
||||
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetExportRoutePattern = '**/api/assets/export'
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/**
|
||||
* Media kinds supported by the assets sidebar filter UI. The string values
|
||||
* match what the backend stores on `preview_output.mediaType` (`images` is
|
||||
* intentionally plural to match existing API conventions; the others are
|
||||
* singular as emitted by `useMediaAssetGalleryStore`).
|
||||
*
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
video: 'mp4',
|
||||
audio: 'wav',
|
||||
'3D': 'glb'
|
||||
}
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & {
|
||||
id: string
|
||||
/**
|
||||
* Optional shorthand to set both `preview_output.mediaType` and an
|
||||
* extension-appropriate filename. Ignored when `preview_output` is also
|
||||
* supplied via `overrides`.
|
||||
*/
|
||||
mediaKind?: MediaKindFixture
|
||||
}
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const { mediaKind, ...rest } = overrides
|
||||
const now = Date.now()
|
||||
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
|
||||
const mediaType = mediaKind ?? 'images'
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${rest.id}.${extension}`,
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...rest
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,46 +54,6 @@ export function createMockJobs(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one job per requested media kind, in the order supplied. Jobs share
|
||||
* a stable timestamp ordering (newer first) so callers can rely on the result
|
||||
* order when mediaType filters are inactive.
|
||||
*/
|
||||
export function createMixedMediaJobs(
|
||||
kinds: MediaKindFixture[]
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return kinds.map((kind, i) =>
|
||||
createMockJob({
|
||||
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
|
||||
mediaKind: kind,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create jobs with explicit `(create_time, execution duration)` pairs so that
|
||||
* sort assertions for newest/oldest and longest/fastest are unambiguous.
|
||||
*
|
||||
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
|
||||
* equals `durationMs`. The first spec becomes id `job-001`, etc.
|
||||
*/
|
||||
export function createJobsWithExecutionTimes(
|
||||
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
|
||||
): RawJobListItem[] {
|
||||
return specs.map((spec, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: spec.createTime,
|
||||
execution_start_time: spec.createTime,
|
||||
execution_end_time: spec.createTime + spec.durationMs
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
@@ -168,23 +88,12 @@ function getExecutionDuration(job: RawJobListItem): number {
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private cloudAssetsResponse: ListAssetsResponse | null = null
|
||||
private assetExportRequests: CreateAssetExportData['body'][] = []
|
||||
private assetExportResponse: CreateAssetExportResponse | null = null
|
||||
private importedFiles: string[] = []
|
||||
private readonly jobDetailRouteHandlers = new Map<
|
||||
string,
|
||||
(route: Route) => Promise<void>
|
||||
>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -261,82 +170,6 @@ export class AssetsHelper {
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
|
||||
this.cloudAssetsResponse = response
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cloudAssetsRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.cloudAssetsResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyCloudAssets(): Promise<void> {
|
||||
await this.mockCloudAssets({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
})
|
||||
}
|
||||
|
||||
async captureAssetExportRequests(
|
||||
response: CreateAssetExportResponse = {
|
||||
task_id: 'asset-export-task',
|
||||
status: 'created'
|
||||
}
|
||||
): Promise<CreateAssetExportData['body'][]> {
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = response
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
this.assetExportRouteHandler = async (route: Route) => {
|
||||
this.assetExportRequests.push(
|
||||
route.request().postDataJSON() as CreateAssetExportData['body']
|
||||
)
|
||||
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.assetExportResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
|
||||
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
|
||||
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
|
||||
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
|
||||
|
||||
if (existingHandler) {
|
||||
await this.page.unroute(pattern, existingHandler)
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(detail)
|
||||
})
|
||||
}
|
||||
|
||||
this.jobDetailRouteHandlers.set(pattern, handler)
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
@@ -392,9 +225,6 @@ export class AssetsHelper {
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.cloudAssetsResponse = null
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = null
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
@@ -402,22 +232,6 @@ export class AssetsHelper {
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetsListRoutePattern,
|
||||
this.cloudAssetsRouteHandler
|
||||
)
|
||||
this.cloudAssetsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetExportRoutePattern,
|
||||
this.assetExportRouteHandler
|
||||
)
|
||||
this.assetExportRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
@@ -433,10 +247,5 @@ export class AssetsHelper {
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
|
||||
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.jobDetailRouteHandlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,7 @@ import type { Page } from '@playwright/test'
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
private readonly appUrl: string
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
}
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
@@ -38,7 +34,7 @@ export class CloudAuthHelper {
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto(`${this.appUrl}/api/users`)
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
GraphAddOptions,
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
@@ -43,45 +39,6 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node to the graph by type.
|
||||
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
|
||||
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
|
||||
* true and cursorPosition is provided, a synthetic MouseEvent is created
|
||||
* as the dragEvent.
|
||||
* @param cursorPosition - Client coordinates for ghost placement dragEvent
|
||||
*/
|
||||
async addNode(
|
||||
type: string,
|
||||
options?: Omit<GraphAddOptions, 'dragEvent'>,
|
||||
cursorPosition?: Position
|
||||
): Promise<NodeReference> {
|
||||
const id = await this.page.evaluate(
|
||||
([nodeType, opts, cursor]) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)!
|
||||
const addOpts: Record<string, unknown> = { ...opts }
|
||||
if (opts?.ghost && cursor) {
|
||||
addOpts.dragEvent = new MouseEvent('click', {
|
||||
clientX: cursor.x,
|
||||
clientY: cursor.y
|
||||
})
|
||||
}
|
||||
window.app!.graph.add(node, addOpts as GraphAddOptions)
|
||||
return node.id
|
||||
},
|
||||
[type, options ?? {}, cursorPosition ?? null] as const
|
||||
)
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
}
|
||||
|
||||
/** Remove all nodes from the graph and clean. */
|
||||
async clearGraph() {
|
||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
AssetInfo,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
HubLabelListResponse,
|
||||
HubProfile,
|
||||
WorkflowPublishInfo
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
|
||||
|
||||
const DEFAULT_PROFILE: HubProfile = {
|
||||
username: 'testuser',
|
||||
display_name: 'Test User',
|
||||
description: 'A test creator',
|
||||
avatar_url: undefined
|
||||
}
|
||||
|
||||
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
|
||||
{ name: 'anime', display_name: 'anime', type: 'tag' },
|
||||
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
|
||||
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
|
||||
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
|
||||
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
|
||||
]
|
||||
|
||||
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
|
||||
workflow_id: 'test-workflow-id-456',
|
||||
share_id: 'test-share-id-123',
|
||||
publish_time: new Date().toISOString(),
|
||||
listed: true,
|
||||
assets: []
|
||||
}
|
||||
|
||||
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
|
||||
upload_url: 'https://mock-s3.example.com/upload',
|
||||
public_url: 'https://mock-s3.example.com/asset.png',
|
||||
token: 'mock-upload-token'
|
||||
}
|
||||
|
||||
export class PublishApiHelper {
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockProfile(profile: HubProfile | null): Promise<void> {
|
||||
await this.addRoute('**/hub/profiles/me', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (profile === null) {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(profile)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockTagLabels(
|
||||
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
|
||||
): Promise<void> {
|
||||
const response: HubLabelListResponse = { labels }
|
||||
await this.addRoute('**/hub/labels**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishStatus(
|
||||
status: 'unpublished' | WorkflowPublishInfo
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (status === 'unpublished') {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(status)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
|
||||
const response: ShareableAssetsResponse = { assets }
|
||||
await this.addRoute('**/assets/from-workflow', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflow(
|
||||
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflowError(
|
||||
statusCode = 500,
|
||||
message = 'Failed to publish workflow'
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: statusCode,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockUploadUrl(
|
||||
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/hub/assets/upload-url', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async setupDefaultMocks(options?: {
|
||||
hasProfile?: boolean
|
||||
hasPrivateAssets?: boolean
|
||||
}): Promise<void> {
|
||||
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
|
||||
|
||||
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
|
||||
await this.mockTagLabels()
|
||||
await this.mockPublishStatus('unpublished')
|
||||
await this.mockShareableAssets(
|
||||
hasPrivateAssets
|
||||
? [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'my_model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
await this.mockPublishWorkflow()
|
||||
await this.mockUploadUrl()
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
}
|
||||
|
||||
private async addRoute(
|
||||
pattern: string,
|
||||
handler: (route: Route) => Promise<void>
|
||||
): Promise<void> {
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
private async removeRoutes(pattern: string): Promise<void> {
|
||||
const handlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern === pattern
|
||||
)
|
||||
for (const { handler } of handlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern !== pattern
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const publishFixture = comfyPageFixture.extend<{
|
||||
publishApi: PublishApiHelper
|
||||
publishDialog: PublishDialog
|
||||
}>({
|
||||
publishApi: async ({ comfyPage }, use) => {
|
||||
const helper = new PublishApiHelper(comfyPage.page)
|
||||
await use(helper)
|
||||
await helper.cleanup()
|
||||
},
|
||||
publishDialog: async ({ comfyPage }, use) => {
|
||||
await use(new PublishDialog(comfyPage.page))
|
||||
}
|
||||
})
|
||||
@@ -59,9 +59,6 @@ export const TestIds = {
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
@@ -212,18 +209,6 @@ export const TestIds = {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
},
|
||||
publish: {
|
||||
dialog: 'publish-dialog',
|
||||
savePrompt: 'publish-save-prompt',
|
||||
describeStep: 'publish-describe-step',
|
||||
finishStep: 'publish-finish-step',
|
||||
footer: 'publish-footer',
|
||||
profilePrompt: 'publish-profile-prompt',
|
||||
nav: 'publish-nav',
|
||||
gateFlow: 'publish-gate-flow',
|
||||
nameInput: 'publish-name-input',
|
||||
tagsInput: 'publish-tags-input'
|
||||
},
|
||||
loading: {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
|
||||
@@ -56,13 +56,7 @@ export function writePerfReport(
|
||||
gitSha = process.env.GITHUB_SHA ?? 'local',
|
||||
branch = process.env.GITHUB_HEAD_REF ?? 'local'
|
||||
) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync('test-results', { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!entries.length) return
|
||||
if (!readdirSync('test-results', { withFileTypes: true }).length) return
|
||||
|
||||
let tempFiles: string[]
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -13,35 +12,25 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
|
||||
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
stubCloudAssets: [
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const pattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
}
|
||||
await page.route(pattern, assetsRouteHandler)
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern, assetsRouteHandler)
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
@@ -53,36 +42,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for the checkpoint asset query to complete and the existing widget
|
||||
// to upgrade into asset mode before creating a fresh node. The current
|
||||
// default node may keep a previously resolved value; what matters is that
|
||||
// new nodes resolve against the cloud asset list after the fetch.
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) => {
|
||||
const includeTags =
|
||||
new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags.split(',').includes('checkpoints')
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate((waitingForWidgetType) => {
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return (
|
||||
node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type ?? waitingForWidgetType
|
||||
)
|
||||
}, WAITING_FOR_WIDGET_TYPE),
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
@@ -105,22 +81,15 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(
|
||||
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return waitingForWidgetType
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? waitingForWidgetValue : val
|
||||
},
|
||||
{
|
||||
id: nodeId,
|
||||
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
|
||||
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
|
||||
}
|
||||
),
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return 'waiting:type'
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? 'waiting:value' : val
|
||||
}, nodeId),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
|
||||
|
||||
const PUBLISH_FEATURE_FLAGS = {
|
||||
comfyhub_upload_enabled: true,
|
||||
comfyhub_profile_gate_enabled: true
|
||||
} as const
|
||||
|
||||
async function saveAndOpenPublishDialog(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: PublishDialog,
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
} catch {
|
||||
// No overwrite dialog — workflow name was unique.
|
||||
}
|
||||
|
||||
await dialog.open()
|
||||
}
|
||||
|
||||
test.describe('Publish dialog - wizard navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
|
||||
})
|
||||
|
||||
test('opens on the Describe step by default', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
await expect(publishDialog.nameInput).toBeVisible()
|
||||
await expect(publishDialog.descriptionTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test('pre-fills workflow name from active workflow', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
|
||||
})
|
||||
|
||||
test('Next button navigates to Examples step', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
// Examples step should show thumbnail toggle and upload area
|
||||
await expect(
|
||||
publishDialog.root.getByText('Select a thumbnail')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back button returns to Describe step from Examples', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
|
||||
await publishDialog.goBack()
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('navigates through all steps to Finish', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext() // → Examples
|
||||
await publishDialog.goNext() // → Finish
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking nav step navigates directly', async ({ publishDialog }) => {
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.goToStep('Describe your workflow')
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(publishDialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Describe step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
|
||||
})
|
||||
|
||||
test('allows editing the workflow name', async ({ publishDialog }) => {
|
||||
await publishDialog.nameInput.clear()
|
||||
await publishDialog.nameInput.fill('My Custom Workflow')
|
||||
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
|
||||
})
|
||||
|
||||
test('allows editing the description', async ({ publishDialog }) => {
|
||||
await publishDialog.descriptionTextarea.fill(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
await expect(publishDialog.descriptionTextarea).toHaveValue(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
})
|
||||
|
||||
test('displays tag suggestions from mocked API', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.root.getByText('anime')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
|
||||
// the active list during E2E. Needs investigation of the parent state
|
||||
// binding.
|
||||
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
|
||||
await publishDialog.root.getByText('anime').click()
|
||||
|
||||
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Examples step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
|
||||
await publishDialog.goNext() // Navigate to Examples step
|
||||
})
|
||||
|
||||
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Video', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image comparison', { exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows example image upload tile', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByRole('button', { name: 'Upload example image' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile card with username', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
|
||||
})
|
||||
|
||||
test('publish button is enabled when no private assets', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with private assets', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({
|
||||
hasProfile: true,
|
||||
hasPrivateAssets: true
|
||||
})
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('publish button is disabled until assets acknowledged', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeDisabled()
|
||||
|
||||
const checkbox = publishDialog.finishStep.getByRole('checkbox')
|
||||
await checkbox.check()
|
||||
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - no profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: false })
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-noprofile-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile creation prompt when user has no profile', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.profilePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking create profile CTA shows profile creation form', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.root
|
||||
.getByRole('button', { name: 'Create a profile' })
|
||||
.click()
|
||||
await expect(publishDialog.gateFlow).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - unsaved workflow', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
// Don't save workflow — open dialog on the default temporary workflow
|
||||
})
|
||||
|
||||
test('shows save prompt for temporary workflow', async ({
|
||||
comfyPage,
|
||||
publishDialog
|
||||
}) => {
|
||||
// Create a new workflow to ensure it's temporary
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await publishDialog.open()
|
||||
|
||||
await expect(publishDialog.savePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText(
|
||||
'You must save your workflow before publishing'
|
||||
)
|
||||
).toBeVisible()
|
||||
// Nav should be hidden when save is required
|
||||
await expect(publishDialog.nav).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - submission', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
})
|
||||
|
||||
test('successful publish closes dialog', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('failed publish shows error toast', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
// Override publish mock with error response
|
||||
await publishApi.mockPublishWorkflowError(500, 'Internal error')
|
||||
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-submit-fail-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
|
||||
// Error toast should appear
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
// Dialog should remain open
|
||||
await expect(publishDialog.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -22,14 +22,18 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: centerX, y: centerY }
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
return { nodeId, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
@@ -76,6 +80,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
@@ -148,127 +153,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('moving ghost onto existing node and clicking places correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get existing KSampler node from the default workflow
|
||||
const [ksamplerRef] =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
const ksamplerPos = await ksamplerRef.getPosition()
|
||||
const ksamplerSize = await ksamplerRef.getSize()
|
||||
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
|
||||
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
|
||||
|
||||
// Start ghost placement away from the existing node
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const ghostRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: startX, y: startY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Move ghost onto the existing node
|
||||
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click to finalize — on top of the existing node
|
||||
await comfyPage.page.mouse.click(targetX, targetY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Ghost should be placed (no longer ghost)
|
||||
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
|
||||
expect(ghostResult).not.toBeNull()
|
||||
expect(ghostResult!.ghost).toBe(false)
|
||||
|
||||
// Ghost node should have moved from its start position toward where we clicked
|
||||
const ghostPos = await ghostRef.getPosition()
|
||||
expect(
|
||||
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
|
||||
).toBe(true)
|
||||
|
||||
// Existing node should NOT be selected
|
||||
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
|
||||
expect(selectedIds).not.toContain(ksamplerRef.id)
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph blueprint added from search box enters ghost mode',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
|
||||
// Convert a node to a subgraph and publish it as a blueprint
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
|
||||
expect(subgraphNodes).toHaveLength(1)
|
||||
const subgraphNode = subgraphNodes[0]
|
||||
|
||||
const blueprintName = `ghost-test-${Date.now()}`
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
name: blueprintName
|
||||
})
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
|
||||
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Open v2 search box and search for the published blueprint
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill(blueprintName)
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// Click the result to add the node (v2 search box uses ghost mode)
|
||||
await searchBoxV2.results.first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// A new node should exist on the graph in ghost mode
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
|
||||
|
||||
const ghostNodeId = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.state.ghostNodeId
|
||||
})
|
||||
expect(ghostNodeId).not.toBeNull()
|
||||
|
||||
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(ghostState).not.toBeNull()
|
||||
expect(ghostState!.ghost).toBe(true)
|
||||
|
||||
// Wait for search box to close, then click to confirm placement
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await comfyPage.nextFrame()
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page.mouse.click(
|
||||
Math.round(viewport.width / 2),
|
||||
Math.round(viewport.height / 2)
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(afterPlace).not.toBeNull()
|
||||
expect(afterPlace!.ghost).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Bookmarked filter shows only bookmarked nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSampler'
|
||||
@@ -68,7 +66,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.filterBarButton('Bookmarked').click()
|
||||
await searchBoxV2.categoryButton('favorites').click()
|
||||
|
||||
await expect(searchBoxV2.results).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toContainText('KSampler')
|
||||
@@ -103,7 +101,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
|
||||
// Type to narrow and select MODEL
|
||||
await searchBoxV2.filterSearch.fill('MODEL')
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
|
||||
@@ -97,7 +97,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
// Apply Input filter with MODEL type
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await searchBoxV2.filterSearch.fill('MODEL')
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
|
||||
@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transform1)
|
||||
|
||||
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(cursor).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -187,10 +187,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width + 20,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
@@ -411,7 +408,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
// default 512 + slider step 64 = 576
|
||||
.toBe(576)
|
||||
})
|
||||
|
||||
@@ -497,29 +493,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should start empty'
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
await painterWidget
|
||||
.getByTestId('painter-clear-button')
|
||||
.dispatchEvent('click')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should still be empty after clearing empty canvas'
|
||||
})
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Serialization', () => {
|
||||
@@ -587,6 +560,36 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
test.describe('Eraser', () => {
|
||||
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
@@ -601,318 +604,18 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Serialization — unchanged canvas', () => {
|
||||
test(
|
||||
'Unchanged canvas does not re-upload on second serialization',
|
||||
{ tag: '@slow' },
|
||||
async ({ comfyPage }) => {
|
||||
let uploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(uploadCount, 'first serialization should upload once').toBe(1)
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(
|
||||
uploadCount,
|
||||
'second serialization without new drawing should not re-upload'
|
||||
).toBe(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Settings persistence', () => {
|
||||
test('Tool selection is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
|
||||
| string
|
||||
| undefined
|
||||
}),
|
||||
{ message: 'painterTool property should update to eraser' }
|
||||
)
|
||||
.toBe('eraser')
|
||||
})
|
||||
|
||||
test('Brush size change is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const sizeRow = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
.getByTestId('painter-size-row')
|
||||
const sizeSlider = sizeRow.getByRole('slider')
|
||||
|
||||
await expect(
|
||||
sizeRow.getByTestId('painter-size-value'),
|
||||
'brush size should start at default 20'
|
||||
).toHaveText('20')
|
||||
|
||||
await sizeSlider.focus()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sizeSlider.press('ArrowRight')
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties
|
||||
?.painterBrushSize as number | undefined
|
||||
}),
|
||||
{ message: 'painterBrushSize property should update to 30' }
|
||||
)
|
||||
.toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
if (node) {
|
||||
node.size = [200, 400]
|
||||
window.app!.canvas.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
const hasContentAtRow = (yFraction: number) =>
|
||||
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cy = Math.floor(el.height * y)
|
||||
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
}, yFraction)
|
||||
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.25), {
|
||||
message: 'top stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.5), {
|
||||
message: 'middle stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.75), {
|
||||
message: 'bottom stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Painter — input image connection',
|
||||
{ tag: ['@widget', '@vue-nodes', '@slow'] },
|
||||
() => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
|
||||
})
|
||||
|
||||
test('Width, height, and bg_color controls hide when input is connected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-width-row'),
|
||||
'width row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-height-row'),
|
||||
'height row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-bg-color-row'),
|
||||
'background color row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-dimension-text'),
|
||||
'dimension text should be visible when input is connected'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas resizes to match input image dimensions after execution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{
|
||||
message: 'input image should be fully decoded',
|
||||
timeout: 30_000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
||||
nw: el.naturalWidth,
|
||||
nh: el.naturalHeight
|
||||
}))
|
||||
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas width should match input image natural width'
|
||||
})
|
||||
.toBe(nw)
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
|
||||
message: 'canvas height should match input image natural height'
|
||||
})
|
||||
.toBe(nh)
|
||||
})
|
||||
|
||||
test('Drawing over input image produces content on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{ message: 'input image should be fully decoded', timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas should resize to match input image width',
|
||||
timeout: 15_000
|
||||
})
|
||||
.toBe(nw)
|
||||
|
||||
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
|
||||
// intercepts coordinate-based hit testing from page.mouse
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
const startX = box.x + box.width * 0.3
|
||||
const endX = box.x + box.width * 0.7
|
||||
const midY = box.y + box.height * 0.5
|
||||
const pointerOpts = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
isPrimary: true
|
||||
}
|
||||
await canvas.dispatchEvent('pointerdown', {
|
||||
...pointerOpts,
|
||||
clientX: startX,
|
||||
clientY: midY
|
||||
})
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await canvas.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + (endX - startX) * (i / 10),
|
||||
clientY: midY
|
||||
})
|
||||
}
|
||||
await canvas.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
clientX: endX,
|
||||
clientY: midY
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'drawing over input image should produce canvas content'
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -99,58 +99,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should clear resolved missing model when Refresh is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await comfyPage.page.route(/\/object_info$/, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's media-type filter menu only renders in cloud mode
|
||||
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
|
||||
// We tag tests `@cloud` so they run against the cloud Playwright project,
|
||||
// and register both `/api/assets` and `/api/jobs` route handlers as auto
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
// MediaAssetCard renders the filename *without* extension via
|
||||
// getFilenameDetails(...).filename, so card-text matching uses the basename.
|
||||
function expectCardText(index: number): string {
|
||||
const filename = MIXED_JOBS[index]?.preview_output?.filename
|
||||
if (!filename) {
|
||||
throw new Error(
|
||||
`MIXED_JOBS[${index}].preview_output.filename is missing — ` +
|
||||
'createMixedMediaJobs contract changed.'
|
||||
)
|
||||
}
|
||||
return filename.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
|
||||
const imageCardName = expectCardText(0)
|
||||
const videoCardName = expectCardText(1)
|
||||
const audioCardName = expectCardText(2)
|
||||
const threeDCardName = expectCardText(3)
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
function makeJobsResponseBody() {
|
||||
return {
|
||||
jobs: MIXED_JOBS,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: MIXED_JOBS.length,
|
||||
total: MIXED_JOBS.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend<{
|
||||
stubCloudAssets: void
|
||||
stubJobs: void
|
||||
stubInputFiles: void
|
||||
}>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse([]))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubJobs: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/api\/jobs(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeJobsResponseBody())
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubInputFiles: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
test('Filter menu opens and exposes all four media-type checkboxes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
await expect(tab.filterImageCheckbox).toBeVisible()
|
||||
await expect(tab.filterVideoCheckbox).toBeVisible()
|
||||
await expect(tab.filterAudioCheckbox).toBeVisible()
|
||||
await expect(tab.filter3DCheckbox).toBeVisible()
|
||||
for (const cb of [
|
||||
tab.filterImageCheckbox,
|
||||
tab.filterVideoCheckbox,
|
||||
tab.filterAudioCheckbox,
|
||||
tab.filter3DCheckbox
|
||||
]) {
|
||||
await expect(cb).toHaveAttribute('aria-checked', 'false')
|
||||
}
|
||||
})
|
||||
|
||||
test('Selecting only "Image" hides non-image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selecting only "Video" hides non-video assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selecting only "Audio" hides non-audio assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Multiple filters combine via OR (image + video)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
|
||||
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Unchecking the active filter restores previously hidden cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
// TODO(#11635): the 3D preview card does not remount after a filter
|
||||
// toggle restores it (only image/video/audio reappear). Image, video,
|
||||
// and audio cover the restoration path; once #11635 is fixed, add the
|
||||
// 3D card back to this assertion list.
|
||||
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
|
||||
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's sort options live inside the settings popover and are
|
||||
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
|
||||
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
|
||||
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
|
||||
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
|
||||
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
|
||||
// page first-loads with mocks already in place.
|
||||
|
||||
// Three jobs whose `(create_time, duration)` axes are intentionally
|
||||
// misaligned so newest/oldest and longest/fastest sorts produce *different*
|
||||
// orderings — preventing false-pass tests where one ordering accidentally
|
||||
// satisfies another.
|
||||
//
|
||||
// spec create_time duration (ms)
|
||||
// ----------------------------------------
|
||||
// job-001 1000 5000 (oldest, mid duration)
|
||||
// job-002 2000 10000 (mid age, longest)
|
||||
// job-003 3000 3000 (newest, shortest)
|
||||
const SORT_JOBS = createJobsWithExecutionTimes([
|
||||
{ createTime: 1000, durationMs: 5000 },
|
||||
{ createTime: 2000, durationMs: 10000 },
|
||||
{ createTime: 3000, durationMs: 3000 }
|
||||
])
|
||||
|
||||
// MediaAssetCard renders the filename *without* extension via
|
||||
// getFilenameDetails(...).filename, so card-text matching uses the basename.
|
||||
const NAME_BY_ID: Record<string, string> = {
|
||||
'job-001': 'output_job-001',
|
||||
'job-002': 'output_job-002',
|
||||
'job-003': 'output_job-003'
|
||||
}
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
function makeJobsResponseBody() {
|
||||
return {
|
||||
jobs: SORT_JOBS,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: SORT_JOBS.length,
|
||||
total: SORT_JOBS.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend<{
|
||||
stubCloudAssets: void
|
||||
stubJobs: void
|
||||
stubInputFiles: void
|
||||
}>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse([]))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubJobs: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/api\/jobs(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeJobsResponseBody())
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
],
|
||||
stubInputFiles: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - sort options', { tag: '@cloud' }, () => {
|
||||
test('Settings menu exposes all four sort options in cloud mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.sortNewestFirst).toBeVisible()
|
||||
await expect(tab.sortOldestFirst).toBeVisible()
|
||||
await expect(tab.sortLongestFirst).toBeVisible()
|
||||
await expect(tab.sortFastestFirst).toBeVisible()
|
||||
})
|
||||
|
||||
test('Default order is newest first (descending create_time)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
// Cards should appear in the order: job-003, job-002, job-001
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-001'])
|
||||
})
|
||||
|
||||
test('"Oldest first" reverses the order', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortOldestFirst.click()
|
||||
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
|
||||
test('"Longest first" puts the slowest job at the top', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortLongestFirst.click()
|
||||
|
||||
// Expected: job-002 (10s), job-001 (5s), job-003 (3s)
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-002'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
|
||||
test('"Fastest first" puts the quickest job at the top', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortFastestFirst.click()
|
||||
|
||||
// Expected: job-003 (3s), job-001 (5s), job-002 (10s)
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
|
||||
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-002'])
|
||||
})
|
||||
|
||||
test('Sort persists when the search input is edited', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(SORT_JOBS.length)
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.sortOldestFirst.click()
|
||||
|
||||
// Type a query that matches all three jobs, then clear it; sort order
|
||||
// must remain "oldest first".
|
||||
await tab.searchInput.fill('output_job')
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
|
||||
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
@@ -65,37 +62,6 @@ const SAMPLE_IMPORTED_FILES = [
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
const JOB_GAMMA_DETAIL: JobDetail = {
|
||||
...SAMPLE_JOBS[2],
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [
|
||||
{
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
},
|
||||
{
|
||||
filename: 'abstract_art_alt.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cloudTest = test.extend<{ mockCloudAssetSidebarData: void }>({
|
||||
mockCloudAssetSidebarData: async ({ comfyPage }, use) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockEmptyCloudAssets()
|
||||
|
||||
await use()
|
||||
|
||||
await comfyPage.assets.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
@@ -667,96 +633,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
cloudTest(
|
||||
'Single job selection uses preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected assets from one job use preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect.poll(() => tab.assetCards.count()).toBe(2)
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters?.['job-gamma']?.toSorted()).toEqual(
|
||||
['abstract_art.png', 'abstract_art_alt.png']
|
||||
)
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected jobs use job-time naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids?.toSorted()).toEqual(['job-alpha', 'job-beta'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('group_by_job_time')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
@@ -1,73 +1,39 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
|
||||
import {
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) {
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
}
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -120,434 +86,54 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
let previousUseNewMenu: unknown
|
||||
|
||||
test.describe('Legacy prefixed proxyWidget normalization', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousUseNewMenu =
|
||||
await comfyPage.settings.getSetting('Comfy.UseNewMenu')
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.UseNewMenu',
|
||||
previousUseNewMenu
|
||||
)
|
||||
})
|
||||
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
try {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
g: typeof graph
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
checkEndpoint(label, 'origin_id', link.origin_id, g),
|
||||
checkEndpoint(label, 'target_id', link.target_id, g)
|
||||
].filter((e): e is string => e !== null)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
|
||||
comfyExpect(warnings).toEqual([])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 = {
|
||||
@@ -285,12 +290,9 @@ export default defineConfig([
|
||||
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
|
||||
}
|
||||
],
|
||||
// Tests routinely define stub and harness components side-by-side with
|
||||
// the system under test and stub emits for documentation only — these
|
||||
// production-SFC rules are noise in a test file.
|
||||
'vue/one-component-per-file': 'off',
|
||||
'vue/no-reserved-component-names': 'off',
|
||||
'vue/no-unused-emit-declarations': 'off'
|
||||
// Tests routinely define stub and harness components side-by-side with the
|
||||
// system under test, which is a distinct use case from production SFCs.
|
||||
'vue/one-component-per-file': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
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
|
||||
|
||||
@@ -17,9 +17,6 @@ const config: KnipConfig = {
|
||||
entry: ['src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}']
|
||||
},
|
||||
'packages/design-system': {
|
||||
project: ['src/**/*.{css,js,ts}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.12",
|
||||
"version": "1.44.10",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -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:",
|
||||
|
||||
@@ -589,6 +589,8 @@
|
||||
background-color: color-mix(in srgb, currentColor 20%, transparent);
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
|
||||
4
packages/ingest-types/src/types.gen.ts
generated
4
packages/ingest-types/src/types.gen.ts
generated
@@ -3825,14 +3825,14 @@ export type CreateAssetExportData = {
|
||||
/**
|
||||
* Strategy for naming files in the ZIP:
|
||||
* - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png")
|
||||
* - group_by_job_time: Group assets by job execution time as parent directories
|
||||
* - prepend_job_id: Prepend job ID to filenames for uniqueness (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4_ComfyUI_00001_.png")
|
||||
* - preserve: Use original asset names, skip duplicates (first one wins)
|
||||
* - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png")
|
||||
*
|
||||
*/
|
||||
naming_strategy?:
|
||||
| 'group_by_job_id'
|
||||
| 'group_by_job_time'
|
||||
| 'prepend_job_id'
|
||||
| 'preserve'
|
||||
| 'asset_id'
|
||||
/**
|
||||
|
||||
2
packages/ingest-types/src/zod.gen.ts
generated
2
packages/ingest-types/src/zod.gen.ts
generated
@@ -1818,7 +1818,7 @@ export const zCreateAssetExportData = z.object({
|
||||
job_ids: z.array(z.string()).optional(),
|
||||
asset_ids: z.array(z.string()).optional(),
|
||||
naming_strategy: z
|
||||
.enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id'])
|
||||
.enum(['group_by_job_id', 'prepend_job_id', 'preserve', 'asset_id'])
|
||||
.optional(),
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
|
||||
}),
|
||||
|
||||
@@ -14495,7 +14495,7 @@ export interface components {
|
||||
* @description The ID of the model to call
|
||||
* @enum {string}
|
||||
*/
|
||||
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit" | "happyhorse-1.0-t2v" | "happyhorse-1.0-i2v" | "happyhorse-1.0-r2v" | "happyhorse-1.0-video-edit";
|
||||
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit";
|
||||
/** @description Enter basic information, such as prompt words, etc. */
|
||||
input: {
|
||||
/**
|
||||
|
||||
@@ -202,28 +202,6 @@ describe('formatUtil', () => {
|
||||
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
|
||||
)
|
||||
})
|
||||
|
||||
it('should highlight cross-word matches', () => {
|
||||
const result = highlightQuery('convert image to mask', 'geto', false)
|
||||
expect(result).toBe(
|
||||
'convert ima<span class="highlight">ge to</span> mask'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not match across line breaks', () => {
|
||||
const result = highlightQuery('ge\nto', 'geto', false)
|
||||
expect(result).toBe('ge\nto')
|
||||
})
|
||||
|
||||
it('should not match across tabs', () => {
|
||||
const result = highlightQuery('ge\tto', 'geto', false)
|
||||
expect(result).toBe('ge\tto')
|
||||
})
|
||||
|
||||
it('should not match across multiple spaces', () => {
|
||||
const result = highlightQuery('ge to', 'geto', false)
|
||||
expect(result).toBe('ge to')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilenameDetails', () => {
|
||||
|
||||
@@ -74,14 +74,10 @@ export function highlightQuery(
|
||||
text = DOMPurify.sanitize(text)
|
||||
}
|
||||
|
||||
// Escape special regex characters, then join with an optional single
|
||||
// space so cross-word matches (e.g. "geto" → "imaGE TO") are
|
||||
// highlighted without spanning tabs, newlines, or multi-space gaps.
|
||||
const pattern = Array.from(query)
|
||||
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('[ ]?')
|
||||
// Escape special regex characters in the query string
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const regex = new RegExp(`(${pattern})`, 'gi')
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi')
|
||||
return text.replace(regex, '<span class="highlight">$1</span>')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { clsx } from 'clsx'
|
||||
import type { ClassArray } from 'clsx'
|
||||
import { extendTailwindMerge } from 'tailwind-merge'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export type { ClassValue } from 'clsx'
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': ['text-xxs', 'text-xxxs']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function cn(...inputs: ClassArray) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -26,7 +26,7 @@ const ScrubableNumberInputStub = defineComponent({
|
||||
step: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user