Compare commits

...

8 Commits

Author SHA1 Message Date
bigcat88
c3a13b5b4e Wait for interactive WebSocket session before queueing 2026-07-11 11:51:22 +03:00
bigcat88
d28875c4d8 feat: add interactive video stream widgets 2026-07-10 23:57:51 +03:00
nav-tej
df9b5bfa0a feat(website): rework MCP page setup + hero, hide promo banner on /mcp (#13514)
## Summary

Two changes to the comfy.org `/mcp` experience: reframe the Setup
section around the agent-driven install and tidy the hero CTAs, and stop
the sitewide MCP promo banner from showing on the page it links to.

## Changes

- **Setup Step 1** is now **"Ask your agent to install Comfy MCP"** with
a multi-line, copyable prompt (`Help me install Comfy MCP. / Follow the
setup guide at https://docs.comfy.org/agent-tools/cloud`).
`CopyableField` gains a `multiline` variant (wraps the text, top-aligns
the copy button); `FeatureGrid01`'s `code` action threads the flag
through.
- **Step 2** becomes the optional manual-connector path (**"Or add it by
hand"**) so the three-step flow stays coherent — no dangling "paste the
URL" that Step 1 no longer copies.
- **Hero** swaps the **"Run a workflow"** primary CTA for **"Install
MCP"**, which anchors to the on-page `#setup` steps; **"View Docs"**
stays as the secondary CTA.
- **Announcement banner** no longer advertises the page you're already
on. `evaluateBannerVisibility` gains a build-time gate: when the current
path matches the banner's CTA destination it's suppressed. Locale prefix
is stripped (so `/zh-CN/mcp` matches an unprefixed `/mcp` href),
trailing slashes are tolerated, and external CTA links never suppress.
Result: the MCP banner shows everywhere except `/mcp` and `/zh-CN/mcp`.
- New i18n keys (`mcp.setup.step1.command`, `mcp.hero.installMcp`) with
en + zh-CN parity; 5 new unit tests cover the banner suppression logic.
- **Breaking**: none.

## Review Focus

- @deepme987 @bertfy — copy + flow check. The Step 1 prompt is a
paraphrase of the `agent-tools/cloud` install guide. The raw
`cloud.comfy.org/mcp` URL is no longer surfaced on the page (the manual
path now points to the MCP docs instead) — flag if you'd rather keep the
raw URL visible in Step 2.
- Banner suppression keys off the CTA's `link.href` — general, so any
future banner pointing at an internal page auto-hides on that page. It's
a **build-time** gate (this is a static site), consistent with the
existing `startsAt`/`endsAt` behavior.
- Only the **hero** CTA changed. The "How it works" section deliberately
keeps its "Run a workflow" CTA.
- zh-CN strings are machine-drafted; a native check would be welcome.

## Screenshots

Verified locally against a production build (`astro build` + preview):

- **Hero** — `INSTALL MCP` + `VIEW DOCS` (the "Run a workflow" button is
gone). "Install MCP" scrolls to `#setup`.
- **Setup** — Step 1 shows the agent-install prompt in a multi-line
copyable field; Step 2 "Or add it by hand"; Step 3 unchanged.
- **Banner** — present on `/`, `/download`, `/cloud`; absent on `/mcp`
and `/zh-CN/mcp`.

A Vercel preview deploy attaches automatically for a live view.

---------

Co-authored-by: imick-io <153135517+imick-io@users.noreply.github.com>
2026-07-09 04:11:23 +00:00
Yousef R. Gamaleldin
a6b7ce11aa feat: add preview support for save text node (CORE:-176) (#12521)
## Summary

Add a frontend preview extension for the SaveText node that displays
saved text content after execution.

## Changes

The extension add a multiline text widget into the SaveText node and
populates it upon onExecuted
PR on core: https://github.com/Comfy-Org/ComfyUI/pull/14102

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

Extension follows the same pattern as previewAny.ts

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots
<img width="1127" height="421" alt="Screenshot 2026-05-29 194925"
src="https://github.com/user-attachments/assets/e7a72807-858b-47c7-be07-595f9e539a49"
/>

<!-- Add screenshots or video recording to help explain your changes -->

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-08 23:27:32 -04:00
Matt Miller
d3b100be8d feat: render BYOK provider surface (labels / logos / help) from server data (#13509)
## ELI-5

The "API Keys & Secrets" settings screen lets you save a key for a
provider (HuggingFace, Civitai, and now video/image API providers). The
list of which providers you can pick is decided by the server. This PR
makes the picker and the saved-keys list show a nice name, logo, and
short help text for each provider the server offers — and, crucially,
actually render providers the server newly lists instead of silently
dropping them.

## What

- The provider dropdown in the add-secret dialog is now driven by the
providers the server returns from `GET /secrets/providers`. Each id is
mapped to its display label, logo, and optional help text through a
small presentational registry.
- Previously the dropdown took a hardcoded known-provider array and
*intersected* it with the server list, so any provider the server listed
that wasn't already hardcoded could never appear. That intersection is
gone: once the server list loads, it renders verbatim.
- Unknown provider ids fall back gracefully to the raw id with no logo,
so adding a provider server-side requires no frontend change; giving it
a first-class label/logo is an optional enhancement.
- The saved-keys list already resolved label/logo through the same
registry, so it picks up the new providers automatically.
- Added provider-specific help text under the picker (falls back to the
generic hint), plus placeholder logo assets under
`public/assets/images/` for the two new API providers.

## Why

Keeps the provider surface data-driven end to end: the server owns
*which* providers are configurable, and the frontend owns *how* each one
renders. This removes the last hardcoded gate that stopped server-listed
providers from showing up.

## Tests

- New `providers.test.ts`: label/logo/help lookups for all known
providers, graceful fallback for unknown ids and `undefined`, and that
the not-loaded fallback list stays the pre-existing baseline (does not
silently include the new providers).
- Extended `useSecretForm.test.ts`: server-listed providers render with
correct labels + logos; providers the server omits do not appear;
provider-specific vs generic help text selection.
- Full secrets suite green (66 tests). Changed files typecheck clean.

Note: the two new provider logos are simple placeholder SVGs and can be
swapped for final brand assets.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 03:07:47 +00:00
Hunter
54b0c10148 fix(auth): stop workspace auth from oscillating to personal identity (#13511)
## Summary

Fixes a "weird stale auth" bug where cloud requests oscillated between
workspace-scoped and personal (Firebase) identity.

**Root cause:** workspace membership lives in two decoupled places —
`teamWorkspaceStore.activeWorkspaceId` (durable intent) and
`workspaceAuthStore` (the mintable token). When the token was
transiently missing while `activeWorkspaceId` was still set (bootstrap
mint in flight, expired token, or a context cleared by a recoverable
refresh failure), `getAuthHeader`/`getAuthToken` silently downgraded to
the personal Firebase token. Depending on timing, consecutive requests
carried different identities, so the backend saw the user flip between
workspace and personal scope.

## Changes

- **On-demand recovery, fail closed:** when a workspace is active,
`getAuthHeader`/`getAuthToken` route through
`ensureWorkspaceToken(activeWorkspaceId)`, which re-mints the token on
demand and returns `null` rather than downgrading. Recovery also
revalidates expiry, so an expired token is reminted instead of sent
stale.
- **`getAuthToken` parity:** WebSocket/queue auth now recovers the same
way (previously only `getAuthHeader` did).
- **Coalescing:** a burst of callers collapses onto a single in-flight
mint (loop re-checks the in-flight promise), and only a token minted for
the requested workspace is accepted.
- **Backoff:** a 5s cooldown after any failed/empty recovery prevents
hammering `POST /auth/token`; reset on a successful mint and on context
teardown.
- **Lifecycle hygiene:** `clearWorkspaceContext()` now resets
`recoveryCooldownUntil` and `inFlightSwitchPromise` so logout/re-login
without a reload isn't wedged.
- **Transient vs permanent:** a missing Firebase ID token while the user
is still signed in (e.g. `NETWORK_REQUEST_FAILED`, which `getIdToken()`
swallows) is treated as transient, not a revoked session.
- **Revoked-workspace reconciliation:** on
`ACCESS_DENIED`/`WORKSPACE_NOT_FOUND`,
`teamWorkspaceStore.forgetRevokedActiveWorkspace()` drops the persisted
selection and reloads to fall back to the personal workspace (skipping
the personal workspace itself to avoid reload loops).
`INVALID_FIREBASE_TOKEN`/`NOT_AUTHENTICATED` do not trigger this.

## Testing

- `pnpm test:unit` for the three affected stores: **210 tests pass**.
- `pnpm lint` and `pnpm typecheck` pass locally (run with a raised Node
heap; the pre-commit/`pnpm typecheck` step OOMs in this environment, so
commits used `--no-verify` — CI should re-run the gates).

Draft pending green CI.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 02:10:13 +00:00
jaeone94
2b540a5281 fix: resolve cloud output video missing-media false positives (#13507)
## Summary

Fix Cloud missing-media false positives for output videos inserted as
loader nodes when the widget value includes a subfoldered output path
such as `video/<hash>.mp4 [output]`, while Cloud output assets expose
the generated media by a flat hash.

## Changes

- **What**: resolve Cloud output candidates with subfolders against
output asset hashes by falling back from the normalized candidate
basename only for Cloud output media.
- **What**: keep that fallback hash-only so unrelated flat output asset
names do not satisfy subfoldered candidates.
- **What**: make Cloud output pagination completion hash-aware so a
colliding `asset.name` does not stop loading before the hash match
appears.
- **What**: add unit regressions plus a small Cloud E2E fixture covering
`LoadVideo` with `video/cloud-video-hash.mp4 [output]`.
- No breaking changes or dependency changes.

## Review Focus

Root cause: Cloud output videos can be inserted as loader nodes with
widget values from workflow metadata that include a media subfolder,
e.g. `video/<hash>.mp4 [output]`. The Cloud output asset list resolves
generated media by a flat hash. The existing missing-media scan compared
the subfoldered candidate against flat asset identifiers, so valid
generated output media could be flagged as missing. Images often did not
reproduce because their metadata commonly lacked the subfolder, so the
existing exact/compact matching happened to work.

Why the fix is scoped to missing-media scan: the inserted widget value
is valid node/workflow state and may carry folder information for other
Cloud/runtime paths. Stripping the subfolder at insertion would broaden
the behavioral change to asset insertion and loader widgets.
Missing-media scan owns the decision of whether a candidate resolves to
an existing Cloud asset, so the smallest production change is to
recognize the Cloud output hash shape there.

Why this is safe for Cloud: the basename fallback is applied only for
candidates annotated as output and only in Cloud. It matches against
output asset hashes, not flat asset names, which prevents unrelated
assets named `<hash>.mp4` from masking a real miss. Input media and
non-Cloud exact path behavior continue to use the existing identifier
matching. Cloud output pagination early-exit is also hash-aware now, so
a flat name collision cannot stop paging before the real hash match is
fetched.

Red-green and validation: added the unit regression first to reproduce
`video/<hash>.mp4 [output]` as missing before the production fix, then
verified it green after the scanner/resolver change. Added a compact
Cloud E2E for the same `LoadVideo` subfoldered output case. Local
validation run:

- `pnpm test:unit src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts` - 59 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "subfoldered output video" --repeat-each=5
--reporter=line` - 5 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "resolves compact annotated output media|resolves
subfoldered output video" --reporter=line` - 2 passed
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm knip`
- `pnpm lint`
- staged pre-commit hooks: oxfmt, oxlint, eslint, typecheck,
typecheck:browser

## Screenshots (if applicable)

Before


https://github.com/user-attachments/assets/4d980546-a981-4764-9c81-4eaed69e4679


After


https://github.com/user-attachments/assets/6a4ddb26-6c16-4cbf-b7bc-b9cd55eece58
2026-07-09 00:31:25 +00:00
ShihChi Huang
51156c5503 chore: route CodeRabbit test reviews through guidance docs (#13486)
## Summary

Adds CodeRabbit `path_instructions` so changed test files are reviewed
with the repo's existing test guidance docs.

## Changes

- **What**: Routes Vitest `**/*.test.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md`.
- **What**: Routes Playwright
`{browser_tests,apps/website/e2e}/**/*.spec.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/playwright.md`.
- **What**: Routes LiteGraph Vitest files to the same Vitest docs plus
`docs/testing/litegraph-testing.md`, which now only documents shared
factory usage and preferring real LiteGraph instances where practical.

## Review Focus

Confirm the path globs cover the intended test files and that
LiteGraph-specific review guidance stays scoped to LiteGraph tests.

## Testing

- Parsed `.coderabbit.yaml` with Ruby YAML
- Ran `git diff --check`
- Commit hooks ran `oxfmt`, `oxlint`, `eslint`, and `pnpm typecheck`
- Push hook ran `knip --cache`

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation and CodeRabbit review configuration only; no runtime or
test execution behavior changes.
> 
> **Overview**
> Configures **CodeRabbit `path_instructions`** so automated reviews of
changed tests pull in the repo’s written testing standards instead of
generic heuristics.
> 
> **Vitest** (`**/*.test.ts`) reviews must treat
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md` as required context. **LiteGraph Vitest**
under `src/lib/litegraph/**/*.test.ts` adds
`docs/testing/litegraph-testing.md`. **Playwright** specs under
`browser_tests/` and `apps/website/e2e/` use the test-quality and
testing README docs plus `docs/guidance/playwright.md`.
> 
> The testing index now lists a fourth guide, **LiteGraph Testing**, and
the new `litegraph-testing.md` doc steers authors toward
`litegraphTestUtils` factories and real LiteGraph instances over broad
mocks.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
efa11d080e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-09 00:03:11 +00:00
92 changed files with 4051 additions and 367 deletions

View File

@@ -63,3 +63,14 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { localizeHref } from '../../config/routes'
import { t } from '../../i18n/translations'
const {
@@ -15,8 +16,7 @@ const {
locale?: Locale
}>()
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
</script>
<template>

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
@@ -11,6 +14,8 @@ const {
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const multiline = computed(() => value.includes('\n'))
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
@@ -20,15 +25,32 @@ function handleCopy() {
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
:class="
cn(
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
multiline ? 'items-start' : 'items-center'
)
"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
<span
:class="
cn(
'flex-1 font-mono text-xs text-primary-comfy-canvas',
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
)
"
>
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
:class="
cn(
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
multiline && 'mt-0.5'
)
"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { isHrefActive } from './useCurrentPath'
describe('isHrefActive', () => {
it('matches the current page', () => {
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
})
it('does not match other pages', () => {
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
})
it('matches regardless of a trailing slash', () => {
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
})
it('ignores query and hash on the href', () => {
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
})
it('never matches an external href', () => {
expect(
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
).toBe(false)
})
it('never matches an empty href', () => {
expect(isHrefActive('', '/mcp')).toBe(false)
})
})

View File

@@ -3,6 +3,7 @@ import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
import { localizeHref } from './routes'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
@@ -73,7 +74,7 @@ export function getBannerData(
: undefined,
link: link
? {
href: link.href,
href: localizeHref(link.href, locale),
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { localizeHref } from './routes'
describe('localizeHref', () => {
it('prefixes an internal path for a non-default locale', () => {
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
})
it('leaves the default locale unprefixed', () => {
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
})
it('passes external URLs through unchanged', () => {
expect(
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
).toBe('https://docs.comfy.org/agent-tools/cloud')
})
it('never prefixes locale-invariant routes', () => {
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
})
})

View File

@@ -47,13 +47,26 @@ const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'enterpriseMsa'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
])
) as unknown as Routes
}
@@ -72,7 +85,6 @@ export const externalLinks = {
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',

View File

@@ -72,6 +72,24 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -112,24 +130,6 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -1872,6 +1872,10 @@ const translations = {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.installMcp': {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
@@ -1909,21 +1913,27 @@ const translations = {
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'zh-CN': '添加连接器'
en: 'Or add it by hand',
'zh-CN': '或手动添加'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',

View File

@@ -7,6 +7,7 @@ import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
@@ -43,12 +44,15 @@ const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'

View File

@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:primary-cta="ctas.installMcp"
:secondary-cta="ctas.docs"
>
<template #media>

View File

@@ -17,7 +17,10 @@ const cards: FeatureCard[] = [
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
}
},
{
@@ -53,6 +56,8 @@ const cards: FeatureCard[] = [
<template>
<FeatureGrid01
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"

View File

@@ -9,16 +9,25 @@ export interface McpCta {
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud

View File

@@ -0,0 +1,37 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadVideo",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -11,6 +11,7 @@ import {
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
interface RoleChangeRequest {
url: string
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>

View File

@@ -33,6 +33,27 @@ export function member(
}
}
/**
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
* this the mint fails and auth cannot resolve the active workspace.
*/
export async function mockWorkspaceTokenMint(
page: Page,
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
) {
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
@@ -46,17 +67,7 @@ export async function mockWorkspace(
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await mockWorkspaceTokenMint(page, ws)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({

View File

@@ -11,6 +11,10 @@ import type {
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* Billing facade consumers — FE-933 (B3) regression.
@@ -81,6 +85,7 @@ async function mockCloudBoot(
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

View File

@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

View File

@@ -1,6 +1,11 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import type {
Asset,
GetAllSettingsResponse,
GetSettingByIdResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
@@ -8,6 +13,7 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { WorkspaceStore } from '@e2e/types/globals'
import {
routeObjectInfoFromSetupApi,
setComboInputOptions
@@ -23,10 +29,11 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const outputVideoHash = 'cloud-video-hash.mp4'
const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const missingMediaObservationMs = 1_000
const missingMediaPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
@@ -60,6 +67,18 @@ const cloudOutputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudOutputVideoAsset: Asset & { hash?: string } = {
id: 'test-output-video-hash-001',
name: 'ComfyUI_00001_.mp4',
hash: outputVideoHash,
size: 4_194_304,
mime_type: 'video/mp4',
tags: ['output'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
@@ -92,10 +111,21 @@ interface CloudUploadAssetState {
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
const completedSurveySetting: GetSettingByIdResponse = {
value: { usage: 'personal' }
}
const allSettings: GetAllSettingsResponse = {}
const body = route
.request()
.url()
.includes('/api/settings/onboarding_survey')
? completedSurveySetting
: allSettings
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
body: JSON.stringify(body)
})
})
await page.route('**/api/userdata**', async (route) => {
@@ -121,7 +151,10 @@ async function routeCloudBootstrapApis(page: Page) {
})
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
const cloudOutputTest = createCloudAssetsFixture([
cloudOutputAsset,
cloudOutputVideoAsset
]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
@@ -225,6 +258,33 @@ function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
function isOutputAssetsRequest(url: string) {
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
}
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
await comfyPage.page.waitForResponse(
(response) =>
response.status() === 200 && isOutputAssetsRequest(response.url())
)
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoErrorsTab(comfyPage: ComfyPage) {
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -327,25 +387,31 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
.toBe(true)
}
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
await comfyPage.nextFrame()
await comfyPage.nextFrame()
let sawErrorOverlay = false
let sawCachedMissingMedia = false
const startedAt = Date.now()
await expect
.poll(
async () => {
const cachedMissingMedia =
await getCachedMissingMediaWarningNames(comfyPage)
sawCachedMissingMedia =
sawCachedMissingMedia || !!cachedMissingMedia?.length
sawErrorOverlay =
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
return (
!sawErrorOverlay &&
Date.now() - startedAt >= missingMediaUploadObservationMs
!sawCachedMissingMedia &&
Date.now() - startedAt >= missingMediaObservationMs
)
},
{
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
intervals: [missingMediaUploadPollMs]
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
intervals: [missingMediaPollMs]
}
)
.toBe(true)
@@ -424,7 +490,7 @@ ossTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -482,18 +548,30 @@ cloudOutputTest.describe(
cloudOutputTest(
'resolves compact annotated output media from output assets',
async ({ cloudAssetRequests, comfyPage }) => {
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_annotation'
)
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'output')
)
)
.toBe(true)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoErrorsTab(comfyPage)
}
)
cloudOutputTest(
'resolves subfoldered output video media from flat output asset hashes',
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_video_subfolder'
)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoErrorsTab(comfyPage)
}
)
@@ -529,7 +607,7 @@ cloudUploadRaceTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
markUploadedCloudAssetAvailable()
await delayedUpload.finishUpload()

View File

@@ -0,0 +1,120 @@
# 11. Derived Credential Lifecycle for Cloud Auth
Date: 2026-07-09
## Status
Proposed
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
Cloud authentication derives several short-lived credentials from a single
source of truth — the Firebase identity (ID token):
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
(`useSessionCookie`),
- and consumer state gated on those credentials, such as **subscription status**
(`useSubscription`).
A recurring class of production bugs traces back to how these derived credentials
are kept fresh rather than to any single code path:
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
Its refresh relies on a `setTimeout` timer that browsers throttle in background
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
call 401s until reload.
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
personal Firebase token, so requests authenticated as the wrong identity.
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
burst on wake/network-swap fans out into concurrent, undeduped subscription
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
return 401/empty, flapping `subscriptionStatus` and the run button.
These are not independent defects. They are symptoms of one design shape: **each
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
one-shot events rather than the source identity, with no coalescing of concurrent
refreshes and with silent fallback to a different identity or a stale value on
failure.** Any credential built this way can go stale, stampede, or downgrade.
## Decision
Treat every derived credential as a pure function of the Firebase identity, and
require all of them to obey the same lifecycle invariants. New auth code must
satisfy these; existing code migrates toward them incrementally.
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
JWT and session cookie are derivations of it, never independent state that can
drift from it.
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
or a definitive failure — never a known-expired one. Validity is checked at the
point of use (expiry-aware), not assumed because a background timer _should_
have refreshed. Timers may be an optimization, never the guarantee.
3. **Single-flight.** Concurrent requests for the same credential share one
in-flight mint/refresh. A refresh burst collapses to a single network call.
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
obtained, fail the request. Never silently substitute a different identity or
scope (e.g. personal token for a workspace request).
5. **Bounded reactive retry.** Invalidation is driven by the source identity
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
derived credential triggers at most one re-mint and one retry, then surfaces
the error.
6. **Explicit scope.** A credential names the identity/workspace it is for.
Coalesced results are verified against the requested scope before use.
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
single-flight, fail-closed, and reconciles a revoked workspace instead of
downgrading; subscription-status and session-cookie creation are now
single-flight so a refresh burst can no longer flap them. It intentionally does
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
re-mint + single retry).
Alternatives considered:
- **Layer more defensive checks per call site.** Rejected: this is what produced
the current state — correctness that depends on every caller remembering to
guard is the defect, not the fix.
- **A single reactive credential store subscribing to Firebase, replacing all
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
these invariants incrementally instead.
## Consequences
### Positive
- Whole categories of failure become structurally hard rather than individually
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
requests (4).
- New auth code has a single checklist to satisfy, and reviewers a single rubric
to apply.
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
reasoning about auth changes.
### Negative
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
transient conditions now show errors instead of degrading quietly, so
transient-vs-permanent classification must be correct.
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
lazy and a backgrounded tab still relies on the next read to heal, leaving a
visible gap against FE-613's reactive ideal.
- Existing lifecycles remain non-uniform during migration, so the mental model is
"target vs. current" until the reactive credential store exists.
## Notes
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
shares the philosophy of designing invariants that make illegal states
unrepresentable rather than guarding against them per call site.
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.

View File

@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
## Creating a New ADR

View File

@@ -4,11 +4,12 @@ This guide provides an overview of testing approaches used in the ComfyUI Fronte
## Testing Documentation
Documentation for unit tests is organized into three guides:
Documentation for unit tests is organized into four guides:
- [Component Testing](./component-testing.md) - How to test Vue components
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
- [LiteGraph Testing](./litegraph-testing.md) - How to test LiteGraph graph, node, link, and workflow behavior
## Testing Structure

View File

@@ -0,0 +1,9 @@
# LiteGraph Testing Guide
This guide covers test patterns for LiteGraph graph, node, link, subgraph, and workflow behavior in ComfyUI Frontend.
## Shared Factories
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling LiteGraph node, canvas, graph, subgraph, or workflow builders.
Use real LiteGraph instances or shared factories when they exercise behavior directly. Avoid mocking LiteGraph classes unless the test is intentionally checking a seam outside LiteGraph itself.

View File

@@ -23,6 +23,23 @@ const EXAMPLE_NODE_DEF: ComfyNodeDef = {
}
describe('validateNodeDef', () => {
it('retains interactive display definitions', () => {
const interactive_ui = [
{
id: 'stream',
kind: 'video_stream',
views: [
{ id: 'source', role: 'local_source' as const, label: 'Webcam' },
{ id: 'result', role: 'remote_output' as const, label: 'Inverted' }
]
}
]
expect(
validateComfyNodeDef({ ...EXAMPLE_NODE_DEF, interactive_ui })
?.interactive_ui
).toEqual(interactive_ui)
})
it('accepts a valid node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})

View File

@@ -273,6 +273,20 @@ const zPriceBadge = z.object({
export type PriceBadge = z.infer<typeof zPriceBadge>
export const zInteractiveUi = z.array(
z.object({
id: z.string().min(1),
kind: z.string().min(1),
views: z.array(
z.object({
id: z.string().min(1),
role: z.enum(['local_source', 'remote_output']),
label: z.string().optional()
})
)
})
)
export const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
@@ -317,7 +331,8 @@ export const zComfyNodeDef = z.object({
/** Category for the Essentials tab. If set, the node appears in Essentials. */
essentials_category: z.string().optional(),
/** Whether the blueprint is a global/installed blueprint (not user-created). */
isGlobal: z.boolean().optional()
isGlobal: z.boolean().optional(),
interactive_ui: zInteractiveUi.optional()
})
export const zAutogrowOptions = z.object({

View File

@@ -414,15 +414,15 @@ describe('formatUtil', () => {
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
it('returns true for image/video/audio/3D/text', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
expect(isPreviewableMediaType('text')).toBe(true)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
it('returns false for other', () => {
expect(isPreviewableMediaType('other')).toBe(false)
})
})

View File

@@ -677,12 +677,7 @@ export function getMediaTypeFromFilename(
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
return mediaType !== 'other'
}
export function formatTime(seconds: number): string {

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -49,6 +49,7 @@
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
</template>
</div>
@@ -75,6 +76,7 @@ import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{

View File

@@ -0,0 +1,21 @@
<template>
<article
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
>
<span v-if="hasError" class="text-muted-foreground">
{{ $t('g.textFailedToLoad') }}
</span>
<template v-else>{{ textContent }}</template>
</article>
</template>
<script setup lang="ts">
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { ResultItemImpl } from '@/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl
}>()
const { textContent, hasError } = useTextFileContent(() => result)
</script>

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTextFileContent } from '@/composables/useTextFileContent'
function stubFetch(response: Partial<Response> | Error) {
const mock =
response instanceof Error
? vi.fn().mockRejectedValue(response)
: vi.fn().mockResolvedValue(response)
vi.stubGlobal('fetch', mock)
return mock
}
describe(useTextFileContent, () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns inline content without fetching', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent } = useTextFileContent(() => ({
content: 'inline text',
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('inline text'))
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches text from the url when no inline content is present', async () => {
const fetchMock = stubFetch({
ok: true,
text: () => Promise.resolve('fetched text')
})
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('fetched text'))
expect(fetchMock).toHaveBeenCalledWith('http://example.com/file.txt')
expect(hasError.value).toBe(false)
})
it('flags an error for a non-ok response', async () => {
stubFetch({ ok: false })
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/missing.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
expect(textContent.value).toBe('')
})
it('flags an error when the fetch rejects', async () => {
stubFetch(new Error('network down'))
const { hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
})
it('resolves empty content when there is no source', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent, isLoading } = useTextFileContent(() => undefined)
await vi.waitFor(() => expect(isLoading.value).toBe(false))
expect(textContent.value).toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,40 @@
import { computedAsync } from '@vueuse/core'
import { ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface TextSource {
content?: string
url?: string
}
export function useTextFileContent(
source: MaybeRefOrGetter<TextSource | undefined>
) {
const isLoading = ref(false)
const hasError = ref(false)
const textContent = computedAsync(
async () => {
hasError.value = false
const { content, url } = toValue(source) ?? {}
if (content !== undefined) return content
if (!url) return ''
const response = await fetch(url)
if (!response.ok) {
hasError.value = true
return ''
}
return await response.text()
},
'',
{
evaluating: isLoading,
onError: () => {
hasError.value = true
}
}
)
return { textContent, isLoading, hasError }
}

View File

@@ -1,5 +1,6 @@
{
"supports_preview_metadata": true,
"supports_manager_v4_ui": true,
"supports_progress_text_metadata": true
"supports_progress_text_metadata": true,
"supports_interactions_v1": true
}

View File

@@ -21,6 +21,7 @@ if (!isCloud) {
import './noteNode'
import './painter'
import './previewAny'
import './saveText'
import './rerouteNode'
import './saveImageExtraOutput'
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)

View File

@@ -1,6 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const { addTextPreviewWidgets, updateTextPreviewWidgets } = vi.hoisted(() => ({
addTextPreviewWidgets: vi.fn(),
updateTextPreviewWidgets: vi.fn()
}))
vi.mock('@/extensions/core/textPreviewWidgets', () => ({
addTextPreviewWidgets,
updateTextPreviewWidgets
}))
const capturedExtensions: ComfyExtension[] = []
@@ -12,103 +23,51 @@ vi.mock('@/services/extensionService', () => ({
})
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
type BeforeRegister = NonNullable<ComfyExtension['beforeRegisterNodeDef']>
interface MockWidget {
name: string
options: Record<string, unknown>
element: { readOnly: boolean }
callback?: (value: unknown) => void
value: unknown
hidden: boolean
label: string
serialize?: boolean
}
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
const createdWidgets: MockWidget[] = []
const nodeType = { prototype: {} } as unknown as Parameters<BeforeRegister>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<BeforeRegister>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<BeforeRegister>[2]
)
vi.mock('@/scripts/widgets', () => {
const create =
(kind: string) =>
(
node: { widgets?: MockWidget[] },
name: string,
_info: unknown,
_app: unknown
) => {
const widget: MockWidget = {
name,
options: {},
element: { readOnly: false },
value: kind === 'BOOLEAN' ? false : '',
hidden: false,
label: ''
}
node.widgets = node.widgets ?? []
node.widgets.push(widget)
createdWidgets.push(widget)
return { widget }
}
return {
ComfyWidgets: {
MARKDOWN: create('MARKDOWN'),
STRING: create('STRING'),
BOOLEAN: create('BOOLEAN')
}
const node = {} as LGraphNode
const proto = nodeType.prototype as {
onNodeCreated?: () => void
onExecuted?: (message: { text?: string }) => void
}
})
return { node, proto }
}
describe('PreviewAny extension', () => {
beforeEach(async () => {
capturedExtensions.length = 0
createdWidgets.length = 0
addTextPreviewWidgets.mockClear()
updateTextPreviewWidgets.mockClear()
vi.resetModules()
await import('./previewAny')
})
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
it('adds the shared text preview widgets on node creation', async () => {
const { node, proto } = await setupNode()
const nodeType = { prototype: {} } as unknown as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
)
const node: { widgets?: MockWidget[] } = {}
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return node
}
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
await setupNode()
expect(addTextPreviewWidgets).toHaveBeenCalledWith(node)
})
const previewMarkdown = createdWidgets.find(
(w) => w.name === 'preview_markdown'
)
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
it('updates the preview with executed text', async () => {
const { node, proto } = await setupNode()
const message = { text: 'hello' }
expect(previewMarkdown).toBeDefined()
expect(previewText).toBeDefined()
expect(previewMode).toBeDefined()
proto.onExecuted!.call(node, message)
// widget.options.serialize === false is what executionUtil.graphToPrompt
// checks to exclude a widget from the API prompt sent to the backend.
// Without this, post-execution widget value updates (the rendered preview
// text) get serialized as inputs, change the cache signature, and cause
// the node to re-execute on the next prompt.
expect(previewMarkdown!.options.serialize).toBe(false)
expect(previewText!.options.serialize).toBe(false)
expect(previewMode!.options.serialize).toBe(false)
expect(updateTextPreviewWidgets).toHaveBeenCalledWith(node, message)
})
})

View File

@@ -4,10 +4,11 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
*/
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { type DOMWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
@@ -16,82 +17,18 @@ useExtensionService().registerExtension({
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) {
if (nodeData.name === 'PreviewAny') {
const onNodeCreated = nodeType.prototype.onNodeCreated
if (nodeData.name !== 'PreviewAny') return
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const showValueWidget = ComfyWidgets['MARKDOWN'](
this,
'preview_markdown',
['MARKDOWN', {}],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showValueWidgetPlain = ComfyWidgets['STRING'](
this,
'preview_text',
['STRING', { multiline: true }],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showAsPlaintextWidget = ComfyWidgets['BOOLEAN'](
this,
'previewMode',
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plaintext', default: false }
],
app
)
showAsPlaintextWidget.widget.callback = (value: boolean) => {
showValueWidget.hidden = !value
showValueWidget.options.hidden = !value
showValueWidgetPlain.hidden = value
showValueWidgetPlain.options.hidden = value
}
showValueWidget.label = 'Preview'
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.options.serialize = false
showValueWidget.element.readOnly = true
showValueWidget.serialize = false
showValueWidgetPlain.label = 'Preview'
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.options.serialize = false
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.serialize = false
// The previewMode toggle is a frontend-only display preference and
// is not declared in the backend INPUT_TYPES, so it must not be
// serialized into the API prompt (would alter the cache signature).
showAsPlaintextWidget.widget.options.serialize = false
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted === null || onExecuted === void 0
? void 0
: onExecuted.apply(this, [message])
const previewWidgets =
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
for (const previewWidget of previewWidgets) {
const text = message.text ?? ''
previewWidget.value = Array.isArray(text)
? (text?.join('\n\n') ?? '')
: text
}
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
}
})

View File

@@ -0,0 +1,24 @@
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.saveText',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'SaveText') return
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
}
})

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
interface MockWidget {
name: string
options: Record<string, unknown>
value?: unknown
serialize?: boolean
}
vi.mock('@/scripts/app', () => ({ app: { rootGraph: { id: 'graph-1' } } }))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
resolveNodeRootGraphId: () => 'graph-1'
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue',
() => ({
default: {}
})
)
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => undefined })
}))
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: class {
name: string
options: Record<string, unknown>
type: string
serialize?: boolean
constructor(obj: {
name: string
options: Record<string, unknown>
type: string
}) {
this.name = obj.name
this.options = obj.options
this.type = obj.type
}
},
addWidget: (node: { widgets?: MockWidget[] }, widget: MockWidget) => {
node.widgets = node.widgets ?? []
node.widgets.push(widget)
}
}))
vi.mock('@/scripts/widgets', () => ({
ComfyWidgets: {
BOOLEAN: (node: { widgets?: MockWidget[] }, name: string) => {
const widget: MockWidget = { name, options: {}, value: false }
node.widgets = node.widgets ?? []
node.widgets.push(widget)
return { widget }
}
}
}))
const { addTextPreviewWidgets, updateTextPreviewWidgets } =
await import('./textPreviewWidgets')
function makeNode(): LGraphNode & { widgets: MockWidget[] } {
return { id: '1', widgets: [] } as unknown as LGraphNode & {
widgets: MockWidget[]
}
}
describe('addTextPreviewWidgets', () => {
it('adds a non-serialized preview widget and a non-serialized mode toggle', () => {
const node = makeNode()
addTextPreviewWidgets(node)
const preview = node.widgets.find((w) => w.name === 'preview_text')
const mode = node.widgets.find((w) => w.name === 'preview_mode')
expect(preview?.type).toBe('textPreview')
expect(preview?.serialize).toBe(false)
expect(preview?.options.serialize).toBe(false)
expect(mode?.options.serialize).toBe(false)
})
})
describe('updateTextPreviewWidgets', () => {
let node: LGraphNode & { widgets: MockWidget[] }
beforeEach(() => {
node = makeNode()
node.widgets.push({ name: 'preview_text', options: {}, value: '' })
})
it('joins array text into the preview widget value', () => {
updateTextPreviewWidgets(node, { text: ['a', 'b'] })
expect(node.widgets[0].value).toBe('a\n\nb')
})
it('writes a plain string message as-is', () => {
updateTextPreviewWidgets(node, { text: 'hello' })
expect(node.widgets[0].value).toBe('hello')
})
})

View File

@@ -0,0 +1,78 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const PREVIEW_WIDGET_NAME = 'preview_text'
const MODE_WIDGET_NAME = 'preview_mode'
const inputSpecTextPreview: CustomInputSpec = {
name: PREVIEW_WIDGET_NAME,
type: 'TEXT_PREVIEW',
isPreview: true
}
export function addTextPreviewWidgets(node: LGraphNode) {
const widgetStore = useWidgetValueStore()
let fallbackValue = ''
const previewWidgetId = () =>
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
PREVIEW_WIDGET_NAME
)
const preview = new ComponentWidgetImpl<string | object>({
node,
name: PREVIEW_WIDGET_NAME,
component: WidgetTextPreview,
inputSpec: inputSpecTextPreview,
type: 'textPreview',
options: {
serialize: false,
hideInPanel: true,
getMinHeight: () => 60,
getValue: () => {
const stored = widgetStore.getWidget(previewWidgetId())?.value
return typeof stored === 'string' ? stored : fallbackValue
},
setValue: (value: string | object) => {
fallbackValue = typeof value === 'string' ? value : ''
const state = widgetStore.getWidget(previewWidgetId())
if (state) state.value = fallbackValue
}
}
})
preview.serialize = false
addWidget(node, preview)
const modeWidget = ComfyWidgets['BOOLEAN'](
node,
MODE_WIDGET_NAME,
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plain text', default: false }
],
app
).widget
modeWidget.options.serialize = false
modeWidget.serialize = false
}
export function updateTextPreviewWidgets(
node: LGraphNode,
message: { text?: string | string[] }
) {
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
if (!preview) return
const text = message.text ?? ''
preview.value = Array.isArray(text) ? text.join('\n\n') : text
}

View File

@@ -306,6 +306,20 @@ describe('Graph Clearing and Callbacks', () => {
expect(graph.nodes.length).toBe(0)
})
test('clear() calls onRemoved() for nodes in subgraph definitions', () => {
const graph = new LGraph()
const subgraph = createTestSubgraph({ rootGraph: graph })
const innerNode = new LGraphNode('InnerNode')
const onRemoved = vi.fn()
innerNode.onRemoved = onRemoved
subgraph.add(innerNode)
graph.subgraphs.set(subgraph.id, subgraph)
graph.clear()
expect(onRemoved).toHaveBeenCalledOnce()
})
test('clear() removes graph-scoped preview and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -409,6 +409,11 @@ export class LGraph
// used to detect changes
this._version = -1
for (const subgraph of this._subgraphs.values()) {
for (const node of subgraph.nodes) {
fireNodeRemovalLifecycle(node)
}
}
this._subgraphs.clear()
// safe clear

View File

@@ -45,6 +45,7 @@
"errorLoadingImage": "Error loading image",
"errorLoadingVideo": "Error loading video",
"failedToDownloadImage": "Failed to download image",
"failedToDownloadFile": "Failed to download file",
"failedToDownloadVideo": "Failed to download video",
"calculatingDimensions": "Calculating dimensions",
"import": "Import",
@@ -58,6 +59,7 @@
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"textFailedToLoad": "Text failed to load",
"liveSamplingPreview": "Live sampling preview",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
@@ -4401,7 +4403,11 @@
"name": "Name",
"namePlaceholder": "e.g., My API Key",
"provider": "Provider",
"providerHint": "Optional. Selecting a provider enables automatic token usage.",
"providerHint": "Select a provider to enable automatic token usage.",
"providerHelp": {
"runway": "Enter your Runway API key. You can find it in your Runway account settings under API keys.",
"gemini": "Enter your Google Gemini API key. You can generate one in Google AI Studio."
},
"secretValue": "Secret Value",
"secretValuePlaceholder": "Enter your API key",
"secretValuePlaceholderEdit": "Enter new value to change",

View File

@@ -6,6 +6,7 @@ import { defineComponent, nextTick, onMounted, ref } from 'vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as FormatUtil from '@/utils/formatUtil'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
@@ -21,14 +22,11 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
supportsWorkflowMetadata: () => true
}))
vi.mock('@/utils/formatUtil', () => ({
vi.mock('@/utils/formatUtil', async (importOriginal) => ({
...(await importOriginal<typeof FormatUtil>()),
isPreviewableMediaType: () => true
}))
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: () => ({ nodeType: 'LoadImage' })
}))
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAssets: vi.fn(),
@@ -105,7 +103,7 @@ interface MediaAssetContextMenuExposed {
let capturedRef: MediaAssetContextMenuExposed | null = null
function mountComponent() {
function mountComponent(targetAsset: AssetItem = asset) {
const onHide = vi.fn()
const { container, unmount } = render(
defineComponent({
@@ -115,7 +113,7 @@ function mountComponent() {
onMounted(() => {
capturedRef = menuRef.value
})
return { menuRef, asset, onHide }
return { menuRef, asset: targetAsset, onHide }
},
template:
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
@@ -151,10 +149,12 @@ type MenuItemWithCommand = MenuItem & {
command: NonNullable<MenuItem['command']>
}
function findMenuItem(label: string): MenuItem | undefined {
return capturedMenu.model.find((item) => item.label === label)
}
function findDownloadMenuItem(): MenuItemWithCommand {
const downloadItem = capturedMenu.model.find(
(item) => item.label === 'mediaAsset.actions.download'
)
const downloadItem = findMenuItem('mediaAsset.actions.download')
if (!downloadItem?.command) {
throw new Error('Download menu item or command was not registered')
}
@@ -184,6 +184,31 @@ describe('MediaAssetContextMenu', () => {
unmount()
})
it('shows insert-as-node for assets with a loader node', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)
expect(
findMenuItem('mediaAsset.actions.insertAsNodeInWorkflow')
).toBeDefined()
unmount()
})
it('hides insert-as-node for text assets without a loader node', async () => {
const { container, unmount } = mountComponent({
...asset,
name: 'result.txt'
})
await showMenu(container)
expect(
findMenuItem('mediaAsset.actions.insertAsNodeInWorkflow')
).toBeUndefined()
unmount()
})
it('routes Download through downloadAssets so multi-output jobs zip', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)

View File

@@ -93,16 +93,10 @@ useDismissableOverlay({
})
const showAddToWorkflow = computed(() => {
// Output assets can always be added
if (assetType === 'output') return true
// Input assets: check if file type is supported by loader nodes
if (assetType === 'input' && asset?.name) {
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
}
return false
// Only file types with a loader node (image/video/audio) can be inserted
if (!asset?.name) return false
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
})
const showWorkflowActions = computed(() => {

View File

@@ -30,7 +30,8 @@ const labelByType: Record<string, string> = {
image: 'sideToolbar.mediaAssets.filterImage',
video: 'sideToolbar.mediaAssets.filterVideo',
audio: 'sideToolbar.mediaAssets.filterAudio',
'3d': 'sideToolbar.mediaAssets.filter3D'
'3d': 'sideToolbar.mediaAssets.filter3D',
text: 'sideToolbar.mediaAssets.filterText'
}
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
@@ -38,11 +39,11 @@ function getCheckbox(type: keyof typeof labelByType): HTMLElement {
}
describe('MediaAssetFilterMenu', () => {
it('renders all four media-type checkboxes', () => {
it('renders all media-type checkboxes', () => {
renderMenu()
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(4)
expect(checkboxes).toHaveLength(5)
for (const type of Object.keys(labelByType)) {
expect(getCheckbox(type)).toBeTruthy()
}

View File

@@ -54,7 +54,8 @@ const filters = [
{ type: 'image', label: 'sideToolbar.mediaAssets.filterImage' },
{ type: 'video', label: 'sideToolbar.mediaAssets.filterVideo' },
{ type: 'audio', label: 'sideToolbar.mediaAssets.filterAudio' },
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' }
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' },
{ type: 'text', label: 'sideToolbar.mediaAssets.filterText' }
]
const toggleMediaType = (type: string) => {

View File

@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTextTop from './MediaTextTop.vue'
function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'result.txt',
tags: [],
kind: 'text',
src: 'http://example.com/result.txt',
...overrides
} as AssetMeta
}
describe('MediaTextTop', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('shows a snippet of the fetched text', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('hello world')
})
vi.stubGlobal('fetch', fetchMock)
render(MediaTextTop, { props: { asset: makeAsset() } })
expect(await screen.findByText('hello world')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith('http://example.com/result.txt')
})
it('prefers preview_url over src', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('preview text')
})
vi.stubGlobal('fetch', fetchMock)
render(MediaTextTop, {
props: {
asset: makeAsset({ preview_url: 'http://server/preview.txt' })
}
})
expect(await screen.findByText('preview text')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith('http://server/preview.txt')
})
})

View File

@@ -1,9 +1,37 @@
<template>
<div class="relative size-full overflow-hidden rounded-sm">
<div
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
class="size-full bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
>
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
<p
v-if="snippet"
class="m-0 size-full overflow-hidden p-2 text-left text-xs/4 wrap-break-word whitespace-pre-wrap text-base-foreground"
>
{{ snippet }}
</p>
<div v-else class="flex size-full items-center justify-center">
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const MAX_SNIPPET_LENGTH = 1000
const { asset } = defineProps<{
asset: AssetMeta
}>()
const { textContent } = useTextFileContent(() => ({
url: asset.preview_url || asset.src
}))
const snippet = computed(() => textContent.value.slice(0, MAX_SNIPPET_LENGTH))
</script>

View File

@@ -73,6 +73,26 @@ describe('useSessionCookie', () => {
expect(globalThis.fetch).not.toHaveBeenCalled()
})
it('createSession coalesces concurrent callers into one POST', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
let resolveFetch: (value: Response) => void = () => {}
vi.mocked(globalThis.fetch).mockReturnValue(
new Promise<Response>((resolve) => {
resolveFetch = resolve
})
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
const { createSession } = useSessionCookie()
const first = createSession()
const second = createSession()
resolveFetch(new Response(null, { status: 204 }))
await Promise.all([first, second])
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
})
it('createSessionOrThrow fails fast on non-success responses', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(

View File

@@ -3,6 +3,9 @@ import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
// Coalesce concurrent rotations (token-refresh bursts) into one POST.
let inFlightCreateSession: Promise<void> | null = null
/**
* Session cookie management for cloud authentication.
* Creates and deletes session cookies on the ComfyUI server.
@@ -48,7 +51,14 @@ export const useSessionCookie = () => {
*/
const createSession = async (): Promise<void> => {
if (!isCloud) return
if (inFlightCreateSession) return inFlightCreateSession
inFlightCreateSession = performCreateSession().finally(() => {
inFlightCreateSession = null
})
return inFlightCreateSession
}
const performCreateSession = async (): Promise<void> => {
const { flags } = useFeatureFlags()
try {
const authStore = useAuthStore()

View File

@@ -319,6 +319,44 @@ describe('useSubscription', () => {
await expect(fetchStatus()).rejects.toThrow()
})
it('coalesces concurrent callers into one fetch', async () => {
let resolveFetch: (value: Response) => void = () => {}
vi.mocked(global.fetch).mockReturnValue(
new Promise<Response>((resolve) => {
resolveFetch = resolve
})
)
const { fetchStatus } = useSubscriptionWithScope()
const first = fetchStatus()
const second = fetchStatus()
resolveFetch({
ok: true,
json: async () => ({ is_active: true })
} as Response)
await Promise.all([first, second])
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('does not downgrade known-good status on a failed fetch', async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ is_active: true, subscription_id: 'sub_1' })
} as Response)
const { fetchStatus, isActiveSubscription } = useSubscriptionWithScope()
await fetchStatus()
expect(isActiveSubscription.value).toBe(true)
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Invalid token' })
} as Response)
await fetchStatus().catch(() => {})
expect(isActiveSubscription.value).toBe(true)
})
})
describe('subscribe', () => {
@@ -720,7 +758,6 @@ describe('useSubscription', () => {
}
vi.mocked(global.fetch)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(cancelledResponse as Response)
@@ -764,7 +801,6 @@ describe('useSubscription', () => {
}
vi.mocked(global.fetch)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(cancelledResponse as Response)

View File

@@ -313,11 +313,19 @@ function useSubscriptionInternal() {
}
}
/**
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
// Coalesce concurrent callers so an auth/session-rotation burst mints one fetch.
let inFlightStatusFetch: Promise<CloudSubscriptionStatusResponse | null> | null =
null
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
if (inFlightStatusFetch) return inFlightStatusFetch
inFlightStatusFetch = performFetchSubscriptionStatus().finally(() => {
inFlightStatusFetch = null
})
return inFlightStatusFetch
}
async function performFetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const response = await fetchWithUnifiedRemint(

View File

@@ -152,8 +152,12 @@ describe('resolveMissingMediaAssetSources', () => {
it('stops reading cloud output asset pages once all requested names are found', async () => {
const target = 'target-output.png'
const outputAsset = makeAsset('ComfyUI_00001_.png', target)
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
makeAssetPage([outputAsset], {
hasMore: true,
total: 501
})
)
const result = await resolveMissingMediaAssetSources({
@@ -163,10 +167,56 @@ describe('resolveMissingMediaAssetSources', () => {
allowCompactSuffix: true
})
expect(result.generatedAssets).toEqual([makeAsset(target)])
expect(result.generatedAssets).toEqual([outputAsset])
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
})
it('stops reading cloud output asset pages when a flat target matches by name', async () => {
const target = 'ComfyUI_00001_.mp4'
const outputAsset = makeAsset(target, 'different-output-hash.mp4')
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([outputAsset], {
hasMore: true,
total: 501
})
)
const result = await resolveMissingMediaAssetSources({
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([target]),
allowCompactSuffix: true
})
expect(result.generatedAssets).toEqual([outputAsset])
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
})
it('does not stop cloud output asset paging on a flat asset name collision', async () => {
const target = 'target-output.mp4'
const collidingNameAsset = makeAsset(target)
const matchingHashAsset = makeAsset('ComfyUI_00001_.mp4', target)
mockGetAssetsPageByTag
.mockResolvedValueOnce(
makeAssetPage([collidingNameAsset], { hasMore: true, total: 501 })
)
.mockResolvedValueOnce(makeAssetPage([matchingHashAsset]))
const result = await resolveMissingMediaAssetSources({
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([target]),
generatedHashRequiredNames: new Set([target]),
allowCompactSuffix: true
})
expect(result.generatedAssets).toEqual([
collidingNameAsset,
matchingHashAsset
])
expect(mockGetAssetsPageByTag).toHaveBeenCalledTimes(2)
})
it('aborts cloud output asset loading when input asset loading fails', async () => {
const inputError = new Error('input failed')
let rejectInputAssets!: (err: Error) => void

View File

@@ -23,6 +23,7 @@ export interface ResolveMissingMediaAssetSourcesOptions {
isCloud: boolean
includeGeneratedAssets: boolean
generatedMatchNames: ReadonlySet<string>
generatedHashRequiredNames?: ReadonlySet<string>
allowCompactSuffix: boolean
}
@@ -35,6 +36,7 @@ export async function resolveMissingMediaAssetSources({
isCloud,
includeGeneratedAssets,
generatedMatchNames,
generatedHashRequiredNames = new Set<string>(),
allowCompactSuffix
}: ResolveMissingMediaAssetSourcesOptions): Promise<MissingMediaAssetSources> {
const pathOptions = { allowCompactSuffix }
@@ -60,6 +62,7 @@ export async function resolveMissingMediaAssetSources({
? fetchGeneratedAssets(controller.signal, {
isCloud,
generatedMatchNames,
generatedHashRequiredNames,
pathOptions
})
: Promise.resolve<AssetItem[]>([]),
@@ -76,6 +79,7 @@ export async function resolveMissingMediaAssetSources({
interface FetchGeneratedAssetsOptions {
isCloud: boolean
generatedMatchNames: ReadonlySet<string>
generatedHashRequiredNames: ReadonlySet<string>
pathOptions: MediaPathDetectionOptions
}
@@ -98,12 +102,18 @@ export function getAssetDetectionNames(
async function fetchGeneratedAssets(
signal: AbortSignal | undefined,
{ isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
{
isCloud,
generatedMatchNames,
generatedHashRequiredNames,
pathOptions
}: FetchGeneratedAssetsOptions
): Promise<AssetItem[]> {
if (isCloud) {
return await fetchCloudGeneratedAssets(
signal,
generatedMatchNames,
generatedHashRequiredNames,
pathOptions
)
}
@@ -118,6 +128,7 @@ async function fetchGeneratedAssets(
async function fetchCloudGeneratedAssets(
signal: AbortSignal | undefined,
targetNames: ReadonlySet<string>,
hashRequiredNames: ReadonlySet<string>,
pathOptions: MediaPathDetectionOptions
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
@@ -140,9 +151,10 @@ async function fetchCloudGeneratedAssets(
for (const asset of batch) {
assets.push(asset)
rememberResolvedTargetNames(
rememberResolvedCloudTargetNames(
asset,
targetNames,
hashRequiredNames,
foundTargetNames,
pathOptions
)
@@ -262,6 +274,28 @@ function rememberResolvedTargetNames(
}
}
function rememberResolvedCloudTargetNames(
asset: AssetItem,
targetNames: ReadonlySet<string>,
hashRequiredNames: ReadonlySet<string>,
foundTargetNames: Set<string>,
options: MediaPathDetectionOptions
) {
if (targetNames.size === 0) return
if (asset.hash) {
for (const name of getMediaPathDetectionNames(asset.hash, options)) {
if (targetNames.has(name)) foundTargetNames.add(name)
}
}
for (const name of getAssetDetectionNames(asset, options)) {
if (!hashRequiredNames.has(name) && targetNames.has(name)) {
foundTargetNames.add(name)
}
}
}
function hasResolvedAllTargetNames(
targetNames: ReadonlySet<string>,
foundTargetNames: ReadonlySet<string>

View File

@@ -559,6 +559,7 @@ describe('verifyMediaCandidates', () => {
isCloud: true,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
generatedHashRequiredNames: new Set(),
allowCompactSuffix: true
})
})
@@ -652,6 +653,7 @@ describe('verifyMediaCandidates', () => {
generatedMatchNames: new Set([
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
]),
generatedHashRequiredNames: new Set(),
allowCompactSuffix: true
})
expect(candidates[0]).toMatchObject({
@@ -660,6 +662,88 @@ describe('verifyMediaCandidates', () => {
})
})
it('matches cloud output videos with history subfolders against flat asset hashes', async () => {
const outputHash = 'cloud-video-hash.mp4'
const candidates = [
makeCandidate('1', `video/${outputHash} [output]`, {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
isMissing: undefined
})
]
const resolveAssetSources = makeAssetResolver(
[],
[makeAsset('ComfyUI_00001_.mp4', outputHash)]
)
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([outputHash]),
generatedHashRequiredNames: new Set([outputHash]),
allowCompactSuffix: true
})
expect(candidates[0]).toMatchObject({
name: `video/${outputHash} [output]`,
isMissing: false
})
})
it('does not match subfoldered cloud output media against unrelated flat asset names', async () => {
const outputHash = 'cloud-video-hash.mp4'
const candidates = [
makeCandidate('1', `video/${outputHash} [output]`, {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
isMissing: undefined
})
]
const resolveAssetSources = makeAssetResolver([], [makeAsset(outputHash)])
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0]).toMatchObject({
name: `video/${outputHash} [output]`,
isMissing: true
})
})
it('stops cloud output paging after a subfoldered candidate matches a flat asset hash', async () => {
const outputHash = 'cloud-video-hash.mp4'
const candidates = [
makeCandidate('1', `video/${outputHash} [output]`, {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
isMissing: undefined
})
]
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([makeAsset('ComfyUI_00001_.mp4', outputHash)], {
hasMore: true
})
)
await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
expect(candidates[0]).toMatchObject({
name: `video/${outputHash} [output]`,
isMissing: false
})
})
it('does not satisfy output annotations with input assets of the same name', async () => {
const candidates = [
makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
@@ -733,6 +817,7 @@ describe('verifyMediaCandidates', () => {
isCloud: false,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
generatedHashRequiredNames: new Set(),
allowCompactSuffix: false
})
expect(candidates[0].isMissing).toBe(true)

View File

@@ -135,6 +135,11 @@ interface MediaVerificationOptions {
resolveAssetSources?: MissingMediaAssetResolver
}
interface GeneratedCandidateMatchNames {
names: Set<string>
hashRequiredNames: Set<string>
}
/**
* Verify media candidates against assets available to the current runtime.
*
@@ -165,6 +170,7 @@ export async function verifyMediaCandidates(
const pathOptions = { allowCompactSuffix: isCloud }
const generatedMatchNames = getGeneratedCandidateMatchNames(
pending,
isCloud,
pathOptions
)
@@ -174,8 +180,9 @@ export async function verifyMediaCandidates(
const assetSources = await resolveAssetSources({
signal,
isCloud,
includeGeneratedAssets: generatedMatchNames.size > 0,
generatedMatchNames,
includeGeneratedAssets: generatedMatchNames.names.size > 0,
generatedMatchNames: generatedMatchNames.names,
generatedHashRequiredNames: generatedMatchNames.hashRequiredNames,
allowCompactSuffix: isCloud
})
inputAssets = assetSources.inputAssets
@@ -189,37 +196,58 @@ export async function verifyMediaCandidates(
const inputAssetIdentifiers = new Set<string>()
const outputAssetIdentifiers = new Set<string>()
const outputAssetHashIdentifiers = new Set<string>()
addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
addAssetHashIdentifiers(
outputAssetHashIdentifiers,
generatedAssets,
pathOptions
)
for (const candidate of pending) {
const detectionNames = getMediaPathDetectionNames(
candidate.name,
pathOptions
)
const type = getAnnotatedMediaPathTypeForDetection(
candidate.name,
pathOptions
)
const identifiers =
type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
const isOutputCandidate = type === 'output'
const identifiers = isOutputCandidate
? outputAssetIdentifiers
: inputAssetIdentifiers
candidate.isMissing = !isCandidateResolved(
candidate,
identifiers,
isOutputCandidate,
isCloud,
outputAssetHashIdentifiers,
pathOptions
)
}
}
function getGeneratedCandidateMatchNames(
candidates: MissingMediaCandidate[],
isCloud: boolean,
pathOptions: { allowCompactSuffix: boolean }
): Set<string> {
): GeneratedCandidateMatchNames {
const names = new Set<string>()
const hashRequiredNames = new Set<string>()
for (const candidate of candidates) {
if (!isGeneratedCandidate(candidate, pathOptions)) continue
names.add(
normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
const normalized = normalizeAnnotatedMediaPathForDetection(
candidate.name,
pathOptions
)
const lookupName = isCloud ? getMediaPathBasename(normalized) : normalized
names.add(lookupName)
if (isCloud && lookupName !== normalized) {
hashRequiredNames.add(lookupName)
}
}
return names
return { names, hashRequiredNames }
}
function isGeneratedCandidate(
@@ -233,6 +261,34 @@ function isGeneratedCandidate(
return type === 'output'
}
function isCandidateResolved(
candidate: MissingMediaCandidate,
identifiers: ReadonlySet<string>,
isOutputCandidate: boolean,
isCloud: boolean,
outputAssetHashIdentifiers: ReadonlySet<string>,
pathOptions: { allowCompactSuffix: boolean }
): boolean {
const detectionNames = getMediaPathDetectionNames(candidate.name, pathOptions)
if (detectionNames.some((name) => identifiers.has(name))) return true
if (!isOutputCandidate || !isCloud) return false
const normalized = normalizeAnnotatedMediaPathForDetection(
candidate.name,
pathOptions
)
const basename = getMediaPathBasename(normalized)
return basename !== normalized && outputAssetHashIdentifiers.has(basename)
}
function getMediaPathBasename(value: string): string {
const separatorIndex = Math.max(
value.lastIndexOf('/'),
value.lastIndexOf('\\')
)
return separatorIndex === -1 ? value : value.slice(separatorIndex + 1)
}
function addAssetIdentifiers(
identifiers: Set<string>,
assets: AssetItem[],
@@ -245,6 +301,19 @@ function addAssetIdentifiers(
}
}
function addAssetHashIdentifiers(
identifiers: Set<string>,
assets: AssetItem[],
pathOptions: { allowCompactSuffix: boolean }
) {
for (const asset of assets) {
if (!asset.hash) continue
for (const name of getMediaPathDetectionNames(asset.hash, pathOptions)) {
identifiers.add(name)
}
}
}
/** Group confirmed-missing candidates by file name into view models. */
export function groupCandidatesByName(
candidates: MissingMediaCandidate[]

View File

@@ -12,6 +12,7 @@ vi.mock('../composables/useSecretForm', () => ({
loading: false,
apiError: '',
providerOptions: [],
providerHelp: '',
handleSubmit: vi.fn()
})
}))

View File

@@ -37,13 +37,24 @@
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
<span class="flex items-center gap-2">
<img
v-if="option.logo"
:src="option.logo"
:alt="option.label"
class="size-4"
/>
{{ option.label }}
</span>
</SelectItem>
</SelectContent>
</Select>
<small v-if="errors.provider" class="text-red-500">
{{ errors.provider }}
</small>
<small v-else class="text-muted">
{{ providerHelp }}
</small>
</div>
<div class="flex flex-col gap-1">
@@ -134,7 +145,7 @@ import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useSecretForm } from '../composables/useSecretForm'
import type { SecretMetadata, SecretProvider } from '../types'
import type { SecretMetadata } from '../types'
const {
secret,
@@ -143,7 +154,7 @@ const {
mode = 'create'
} = defineProps<{
secret?: SecretMetadata
existingProviders?: SecretProvider[]
existingProviders?: string[]
availableProviders?: string[] | null
mode?: 'create' | 'edit'
}>()
@@ -156,13 +167,20 @@ const emit = defineEmits<{
const titleId = useId()
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
useSecretForm({
mode,
secret: () => secret,
existingProviders: () => existingProviders,
availableProviders: () => availableProviders,
visible,
onSaved: () => emit('saved')
})
const {
form,
errors,
loading,
apiError,
providerOptions,
providerHelp,
handleSubmit
} = useSecretForm({
mode,
secret: () => secret,
existingProviders: () => existingProviders,
availableProviders: () => availableProviders,
visible,
onSaved: () => emit('saved')
})
</script>

View File

@@ -13,6 +13,7 @@ vi.mock('../composables/useSecretForm', () => ({
loading: false,
apiError: '',
providerOptions: [],
providerHelp: '',
handleSubmit: vi.fn()
})
}))

View File

@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SecretMetadata, SecretProvider } from '../types'
@@ -226,6 +226,64 @@ describe('useSecretForm', () => {
expect(providerOptions.value.map((o) => o.value)).toEqual(['civitai'])
})
it('dedupes repeated provider ids from the server', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['civitai', 'civitai', 'huggingface'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value.map((o) => o.value)).toEqual([
'civitai',
'huggingface'
])
})
it('renders server-listed BYOK providers with labels and logos', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['runway', 'gemini'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value).toEqual([
{
value: 'runway',
label: 'Runway',
logo: '/assets/images/runway.svg',
disabled: false
},
{
value: 'gemini',
label: 'Google Gemini',
logo: '/assets/images/gemini.svg',
disabled: false
}
])
})
it('omits BYOK providers the server does not list', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['huggingface'],
visible,
onSaved: vi.fn()
})
const values = providerOptions.value.map((o) => o.value)
expect(values).toEqual(['huggingface'])
expect(values).not.toContain('runway')
expect(values).not.toContain('gemini')
})
it('reacts to availableProviders changing', () => {
const visible = ref(true)
const availableProviders = ref<string[] | null>(null)
@@ -244,6 +302,43 @@ describe('useSecretForm', () => {
expect(providerOptions.value.map((o) => o.value)).toEqual(['huggingface'])
})
it('clears a selection the resolved allowlist no longer offers', async () => {
const visible = ref(true)
const availableProviders = ref<string[] | null>(null)
const { form, providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => availableProviders.value,
visible,
onSaved: vi.fn()
})
form.provider = 'civitai'
availableProviders.value = ['huggingface']
await nextTick()
expect(providerOptions.value.map((o) => o.value)).toEqual(['huggingface'])
expect(form.provider).toBeNull()
})
it('keeps a selection the resolved allowlist still offers', async () => {
const visible = ref(true)
const availableProviders = ref<string[] | null>(null)
const { form } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => availableProviders.value,
visible,
onSaved: vi.fn()
})
form.provider = 'huggingface'
availableProviders.value = ['huggingface', 'civitai']
await nextTick()
expect(form.provider).toBe('huggingface')
})
it('ignores the availableProviders filter in edit mode', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
@@ -283,6 +378,51 @@ describe('useSecretForm', () => {
})
})
describe('providerHelp', () => {
it('uses the generic hint when no provider is selected', () => {
const visible = ref(true)
const { providerHelp } = useSecretForm({
mode: 'create',
existingProviders: () => [],
visible,
onSaved: vi.fn()
})
// t() is mocked to echo the key.
expect(providerHelp.value).toBe('secrets.providerHint')
})
it('uses provider-specific help when a BYOK provider is selected', () => {
const visible = ref(true)
const { form, providerHelp } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['runway', 'gemini'],
visible,
onSaved: vi.fn()
})
form.provider = 'runway'
expect(providerHelp.value).toBe('secrets.providerHelp.runway')
form.provider = 'gemini'
expect(providerHelp.value).toBe('secrets.providerHelp.gemini')
})
it('falls back to the generic hint for a provider without a help key', () => {
const visible = ref(true)
const { form, providerHelp } = useSecretForm({
mode: 'create',
existingProviders: () => [],
visible,
onSaved: vi.fn()
})
form.provider = 'huggingface'
expect(providerHelp.value).toBe('secrets.providerHint')
})
})
describe('handleSubmit', () => {
it('calls create API with correct payload', async () => {
const visible = ref(true)

View File

@@ -1,16 +1,22 @@
import { whenever } from '@vueuse/core'
import type { MaybeRefOrGetter } from 'vue'
import { computed, reactive, ref, toValue } from 'vue'
import { computed, reactive, ref, toValue, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { createSecret, SecretsApiError, updateSecret } from '../api/secretsApi'
import { SECRET_PROVIDERS } from '../providers'
import type { SecretErrorCode, SecretMetadata, SecretProvider } from '../types'
import {
DEFAULT_PROVIDER_IDS,
getProviderHelpKey,
getProviderLabel,
getProviderLogo
} from '../providers'
import type { SecretErrorCode, SecretMetadata } from '../types'
interface SecretFormState {
name: string
secretValue: string
provider: SecretProvider | null
// Free-form string: the set of selectable providers is server-driven.
provider: string | null
}
interface SecretFormErrors {
@@ -21,14 +27,15 @@ interface SecretFormErrors {
interface ProviderOption {
label: string
value: SecretProvider
value: string
logo?: string
disabled: boolean
}
interface UseSecretFormOptions {
mode: 'create' | 'edit'
secret?: MaybeRefOrGetter<SecretMetadata | undefined>
existingProviders: MaybeRefOrGetter<SecretProvider[]>
existingProviders: MaybeRefOrGetter<string[]>
availableProviders?: MaybeRefOrGetter<string[] | null>
visible: { value: boolean }
onSaved: () => void
@@ -62,19 +69,50 @@ export function useSecretForm(options: UseSecretFormOptions) {
})
const providerOptions = computed<ProviderOption[]>(() => {
// Which provider ids to render:
// - edit mode: the field is disabled; show the defaults plus the stored
// provider so the disabled selector can display its label/logo.
// - create + list not yet loaded (null): show the defaults as a sensible
// placeholder while the request is in flight.
// - create + list loaded: render exactly what the server returned, so
// providers added server-side (e.g. runway/gemini) appear with no FE
// change and providers omitted server-side do not appear.
const available = toValue(availableProviders)
const providers =
mode === 'edit' || available === null
? SECRET_PROVIDERS
: SECRET_PROVIDERS.filter((p) => available.includes(p.value))
return providers.map((p) => ({
label: p.label,
value: p.value,
disabled:
mode === 'edit' ? false : toValue(existingProviders).includes(p.value)
let ids: string[]
if (mode === 'edit') {
const stored = toValue(secretRef)?.provider
ids =
stored && !DEFAULT_PROVIDER_IDS.some((id) => id === stored)
? [...DEFAULT_PROVIDER_IDS, stored]
: [...DEFAULT_PROVIDER_IDS]
} else {
ids =
available === null ? [...DEFAULT_PROVIDER_IDS] : [...new Set(available)]
}
const existing = toValue(existingProviders)
return ids.map((id) => ({
value: id,
label: getProviderLabel(id),
logo: getProviderLogo(id),
disabled: mode === 'edit' ? false : existing.includes(id)
}))
})
// Once the server allowlist resolves, drop a selection the resolved list no
// longer offers so the user cannot submit an unlisted provider.
watch(providerOptions, (options) => {
if (form.provider && !options.some((o) => o.value === form.provider)) {
form.provider = null
}
})
// Provider-specific help text when the selected provider defines it, else the
// generic hint. Sourced from the presentational registry, not the server.
const providerHelp = computed(() =>
t(getProviderHelpKey(form.provider ?? undefined) ?? 'secrets.providerHint')
)
const apiError = computed(() => {
if (!apiErrorCode.value && !apiErrorMessage.value) return null
switch (apiErrorCode.value) {
@@ -91,10 +129,7 @@ export function useSecretForm(options: UseSecretFormOptions) {
const secret = toValue(secretRef)
if (mode === 'edit' && secret) {
form.name = secret.name
// Stored provider is a free-form string; in edit mode the field is
// disabled and only needs to be non-empty for validation (it is never
// re-sent), so narrowing to the form's known-provider type is safe here.
form.provider = (secret.provider ?? null) as SecretProvider | null
form.provider = secret.provider ?? null
form.secretValue = ''
} else {
form.name = ''
@@ -176,6 +211,7 @@ export function useSecretForm(options: UseSecretFormOptions) {
loading,
apiError,
providerOptions,
providerHelp,
handleSubmit
}
}

View File

@@ -9,7 +9,7 @@ import {
listSecrets,
SecretsApiError
} from '../api/secretsApi'
import type { SecretMetadata, SecretProvider } from '../types'
import type { SecretMetadata } from '../types'
export function useSecrets() {
const { t } = useI18n()
@@ -20,10 +20,8 @@ export function useSecrets() {
const availableProviders = ref<string[] | null>(null)
const operatingSecretId = ref<string | null>(null)
const existingProviders = computed<SecretProvider[]>(() =>
secrets.value
.map((s) => s.provider)
.filter((p): p is SecretProvider => p !== undefined)
const existingProviders = computed<string[]>(() =>
secrets.value.map((s) => s.provider).filter((p): p is string => p != null)
)
async function fetchSecrets() {

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_PROVIDER_IDS,
getProviderHelpKey,
getProviderLabel,
getProviderLogo
} from './providers'
describe('secret provider registry', () => {
it('maps known providers (including BYOK) to display labels', () => {
expect(getProviderLabel('huggingface')).toBe('HuggingFace')
expect(getProviderLabel('civitai')).toBe('Civitai')
expect(getProviderLabel('runway')).toBe('Runway')
expect(getProviderLabel('gemini')).toBe('Google Gemini')
})
it('maps known providers to logos under public/assets/images', () => {
expect(getProviderLogo('huggingface')).toBe('/assets/images/hf-logo.svg')
expect(getProviderLogo('civitai')).toBe('/assets/images/civitai.svg')
expect(getProviderLogo('runway')).toBe('/assets/images/runway.svg')
expect(getProviderLogo('gemini')).toBe('/assets/images/gemini.svg')
})
it('exposes per-provider help keys for the BYOK providers', () => {
expect(getProviderHelpKey('runway')).toBe('secrets.providerHelp.runway')
expect(getProviderHelpKey('gemini')).toBe('secrets.providerHelp.gemini')
// Model-download providers reuse the generic hint (no dedicated help key).
expect(getProviderHelpKey('huggingface')).toBeUndefined()
expect(getProviderHelpKey('civitai')).toBeUndefined()
})
it('falls back to the raw id with no logo/help for unknown providers', () => {
// A provider added server-side but absent from the registry still renders.
expect(getProviderLabel('brand-new-provider')).toBe('brand-new-provider')
expect(getProviderLogo('brand-new-provider')).toBeUndefined()
expect(getProviderHelpKey('brand-new-provider')).toBeUndefined()
})
it('returns an empty label and no logo for an undefined provider', () => {
expect(getProviderLabel(undefined)).toBe('')
expect(getProviderLogo(undefined)).toBeUndefined()
expect(getProviderHelpKey(undefined)).toBeUndefined()
})
it('keeps the not-loaded fallback list to the pre-BYOK baseline', () => {
// Defaults are a transient placeholder only; the authoritative list is the
// server response. They must not silently include BYOK providers.
expect([...DEFAULT_PROVIDER_IDS]).toEqual(['huggingface', 'civitai'])
})
})

View File

@@ -1,30 +1,71 @@
import type { SecretProvider } from './types'
interface ProviderConfig {
value: SecretProvider
/** Provider identifier as returned by `GET /secrets/providers`. */
value: string
/** Human-facing display label. Brand names are intentionally not translated. */
label: string
/** Path to the provider logo under `public/assets/images/`. */
logo: string
/** Optional i18n key for provider-specific help text shown under the picker. */
helpKey?: string
}
export const SECRET_PROVIDERS: ProviderConfig[] = [
/**
* Presentational metadata for known providers: how a provider id renders
* (label, logo, help text). This table is NOT the source of truth for which
* providers a user may configure — that list is server-driven via
* `GET /secrets/providers`. Ids the server returns that are absent here fall
* back to the raw id with no logo, so adding a provider server-side renders
* without an FE change (a dedicated logo/label is an optional enhancement).
*/
const SECRET_PROVIDERS: ProviderConfig[] = [
{
value: 'huggingface',
label: 'HuggingFace',
logo: '/assets/images/hf-logo.svg'
},
{ value: 'civitai', label: 'Civitai', logo: '/assets/images/civitai.svg' }
] as const
{ value: 'civitai', label: 'Civitai', logo: '/assets/images/civitai.svg' },
{
value: 'runway',
label: 'Runway',
logo: '/assets/images/runway.svg',
helpKey: 'secrets.providerHelp.runway'
},
{
value: 'gemini',
label: 'Google Gemini',
logo: '/assets/images/gemini.svg',
helpKey: 'secrets.providerHelp.gemini'
}
]
/**
* Providers shown as a sensible default before the server list resolves, and to
* seed the disabled selector in edit mode. This is NOT the source of truth for
* which providers a user may configure — once `GET /secrets/providers` resolves,
* its list is rendered verbatim.
*/
export const DEFAULT_PROVIDER_IDS = ['huggingface', 'civitai'] as const
function findProvider(
provider: string | undefined
): ProviderConfig | undefined {
if (!provider) return undefined
return SECRET_PROVIDERS.find((p) => p.value === provider)
}
export function getProviderLabel(provider: string | undefined): string {
if (!provider) return ''
const config = SECRET_PROVIDERS.find((p) => p.value === provider)
return config?.label ?? provider
return findProvider(provider)?.label ?? provider
}
export function getProviderLogo(
provider: string | undefined
): string | undefined {
if (!provider) return undefined
const config = SECRET_PROVIDERS.find((p) => p.value === provider)
return config?.logo
return findProvider(provider)?.logo
}
export function getProviderHelpKey(
provider: string | undefined
): string | undefined {
return findProvider(provider)?.helpKey
}

View File

@@ -10,15 +10,18 @@ import type { SecretResponse } from '@comfyorg/ingest-types'
export type SecretMetadata = SecretResponse
/**
* Base providers the UI renders with a dedicated label/logo. The full set of
* configurable providers is data-driven via `GET /secrets/providers`.
* Base providers the UI renders with a dedicated first-class label/logo. This
* union documents the historically-known providers only — the full set of
* configurable providers is data-driven via `GET /secrets/providers`, so the
* selected provider is stored/sent as a free-form string.
*/
export type SecretProvider = 'huggingface' | 'civitai'
export interface SecretCreateRequest {
name: string
secret_value: string
provider?: SecretProvider
/** Provider identifier as returned by `GET /secrets/providers`. */
provider?: string
}
export interface SecretUpdateRequest {

View File

@@ -360,6 +360,35 @@ describe('useTeamWorkspaceStore', () => {
})
})
describe('forgetRevokedActiveWorkspace', () => {
it.for([
{ type: 'team', workspace: mockTeamWorkspace, reloads: 1 },
{ type: 'personal', workspace: mockPersonalWorkspace, reloads: 0 }
])(
'reloads $reloads time(s) when the active $type workspace is revoked',
async ({ workspace, reloads }) => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = workspace.id
store.forgetRevokedActiveWorkspace(workspace.id)
expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(reloads)
expect(mockReload).toHaveBeenCalledTimes(reloads)
}
)
it('is a no-op when the workspace is not the active one', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = mockTeamWorkspace.id
store.forgetRevokedActiveWorkspace('some-other-workspace')
expect(mockReload).not.toHaveBeenCalled()
})
})
describe('createWorkspace', () => {
it('creates workspace and triggers reload', async () => {
const newWorkspace = {

View File

@@ -103,6 +103,14 @@ function setLastWorkspaceId(workspaceId: string): void {
}
}
function clearLastWorkspaceId(): void {
try {
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
console.warn('Failed to clear last workspace ID from localStorage')
}
}
const MAX_OWNED_WORKSPACES = 10
export const MAX_WORKSPACE_MEMBERS = 30
const MAX_INIT_RETRIES = 3
@@ -362,6 +370,20 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
}
}
/**
* Drop a revoked/deleted active workspace and reload so init falls back to the
* personal workspace. Skips the personal workspace to avoid a reload loop.
*/
function forgetRevokedActiveWorkspace(workspaceId: string): void {
if (activeWorkspaceId.value !== workspaceId) return
const revoked = workspaces.value.find((w) => w.id === workspaceId)
if (revoked?.type === 'personal') return
clearLastWorkspaceId()
window.location.reload()
}
/**
* Switch to a different workspace.
* Clears workspace context and reloads the page.
@@ -744,6 +766,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Workspace Actions
switchWorkspace,
forgetRevokedActiveWorkspace,
createWorkspace,
deleteWorkspace,
renameWorkspace,

View File

@@ -11,11 +11,24 @@ import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
const mockGetIdToken = vi.fn()
const mockNotifyTokenRefreshed = vi.fn()
const mockToastAdd = vi.fn()
const mockCurrentUser = vi.hoisted((): { value: { uid: string } | null } => ({
value: null
}))
const mockForgetRevokedActiveWorkspace = vi.fn()
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken,
notifyTokenRefreshed: mockNotifyTokenRefreshed
notifyTokenRefreshed: mockNotifyTokenRefreshed,
get currentUser() {
return mockCurrentUser.value
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
forgetRevokedActiveWorkspace: mockForgetRevokedActiveWorkspace
})
}))
@@ -82,6 +95,7 @@ describe('useWorkspaceAuthStore', () => {
sessionStorage.clear()
mockTeamWorkspacesEnabled.value = true
mockUnifiedCloudAuthEnabled.value = false
mockCurrentUser.value = null
})
afterEach(() => {
@@ -583,6 +597,301 @@ describe('useWorkspaceAuthStore', () => {
})
})
describe('ensureWorkspaceAuthHeader', () => {
it('returns the existing header without minting when the token is valid', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('mints a token for the preferred workspace when none exists', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
})
it('coalesces concurrent recovery onto a single mint', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const [first, second] = await Promise.all([
store.ensureWorkspaceAuthHeader('workspace-123'),
store.ensureWorkspaceAuthHeader('workspace-123')
])
expect(first).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(second).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('awaits an in-flight switch instead of racing a second mint', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
let resolveResponse: (value: unknown) => void = () => {}
const responsePromise = new Promise((resolve) => {
resolveResponse = resolve
})
const mockFetch = vi.fn().mockReturnValue(responsePromise)
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const switchPromise = store.switchWorkspace('workspace-123')
const ensurePromise = store.ensureWorkspaceAuthHeader('workspace-123')
resolveResponse({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
await switchPromise
const header = await ensurePromise
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('returns null (never a downgrade) when recovery fails transiently', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toBeNull()
})
it('returns null when there is no workspace to recover to', async () => {
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader()
expect(header).toBeNull()
})
it('is a no-op returning null when the feature flag is disabled', async () => {
mockTeamWorkspacesEnabled.value = false
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toBeNull()
expect(mockFetch).not.toHaveBeenCalled()
})
it('tears down workspace context and surfaces a toast on a permanent recovery failure', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
const token = await store.ensureWorkspaceToken('workspace-999')
expect(token).toBeNull()
expect(currentWorkspace.value).toBeNull()
expect(mockToastAdd).toHaveBeenCalledTimes(1)
})
it('backs off re-minting after a failed recovery instead of retrying every call', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const first = await store.ensureWorkspaceToken('workspace-123')
const second = await store.ensureWorkspaceToken('workspace-123')
expect(first).toBeNull()
expect(second).toBeNull()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('forgets the revoked active workspace on a permanent workspace-selection failure', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
)
const store = useWorkspaceAuthStore()
const token = await store.ensureWorkspaceToken('workspace-123')
expect(token).toBeNull()
expect(mockForgetRevokedActiveWorkspace).toHaveBeenCalledWith(
'workspace-123'
)
})
it('does not forget the workspace when the failure is an auth error, not revocation', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: () => Promise.resolve({ message: 'Invalid token' })
})
)
const store = useWorkspaceAuthStore()
const token = await store.ensureWorkspaceToken('workspace-123')
expect(token).toBeNull()
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
})
it('preserves a valid context on a transient Firebase network failure while signed in', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
mockCurrentUser.value = { uid: 'user-1' }
mockGetIdToken.mockResolvedValue(undefined)
const token = await store.ensureWorkspaceToken('workspace-999')
expect(token).toBeNull()
expect(currentWorkspace.value?.id).toBe('workspace-123')
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
})
it('collapses a burst of waiters into a single mint after a shared in-flight switch rejects', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const initialSwitch = store
.switchWorkspace('workspace-123')
.catch(() => {})
const [first, second] = await Promise.all([
store.ensureWorkspaceToken('workspace-123'),
store.ensureWorkspaceToken('workspace-123')
])
await initialSwitch
expect(first).toBe('workspace-token-abc')
expect(second).toBe('workspace-token-abc')
// One failed initial switch + exactly one recovery mint the burst shares.
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('re-mints for the requested workspace rather than returning an in-flight switch to a different one', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockImplementation((_url, options) => {
const { workspace_id: workspaceId } = JSON.parse(options.body)
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: `token-${workspaceId}`,
workspace: { ...mockWorkspace, id: workspaceId }
})
})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const switchPromise = store.switchWorkspace('workspace-other')
const token = await store.ensureWorkspaceToken('workspace-123')
await switchPromise
expect(token).toBe('token-workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
describe('token refresh scheduling', () => {
it('schedules token refresh 5 minutes before expiry', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')

View File

@@ -8,6 +8,7 @@ import {
TOKEN_REFRESH_BUFFER_MS,
WORKSPACE_STORAGE_KEYS
} from '@/platform/workspace/workspaceConstants'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
@@ -40,6 +41,8 @@ export type WorkspaceTokenResponse = z.infer<
const MAX_SCHEDULED_REFRESH_RETRIES = 3
const RECOVERY_COOLDOWN_MS = 5000
export class WorkspaceAuthError extends Error {
constructor(
message: string,
@@ -112,6 +115,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
// Timer state
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
let inFlightSwitchCount = 0
let inFlightSwitchPromise: Promise<void> | null = null
let recoveryCooldownUntil = 0
let scheduledRefreshRetryCount = 0
// The unified lifecycle keeps its own timer + request-id so it never shares
// mutable state with the legacy switchWorkspace/refreshToken machinery.
@@ -371,7 +376,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
}
async function switchWorkspace(workspaceId: string): Promise<void> {
async function performSwitchWorkspace(workspaceId: string): Promise<void> {
if (!flags.teamWorkspacesEnabled) {
return
}
@@ -399,6 +404,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
workspaceToken.value = token
workspaceTokenExpiresAt.value = expiresAt
scheduledRefreshRetryCount = 0
recoveryCooldownUntil = 0
persistToSession(workspace, token, expiresAt)
scheduleTokenRefresh(expiresAt)
@@ -419,6 +425,124 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
}
function switchWorkspace(workspaceId: string): Promise<void> {
const promise = performSwitchWorkspace(workspaceId)
inFlightSwitchPromise = promise
void promise
.catch(() => {})
.finally(() => {
if (inFlightSwitchPromise === promise) {
inFlightSwitchPromise = null
}
})
return promise
}
function hasValidTokenForWorkspace(workspaceId: string | undefined): boolean {
return (
hasValidWorkspaceToken() &&
(workspaceId === undefined || currentWorkspace.value?.id === workspaceId)
)
}
// NOT_AUTHENTICATED while still signed in is a transient network failure, not
// a revoked session, so it must not tear down a valid context.
function isPermanentRecoveryFailure(err: unknown): err is WorkspaceAuthError {
if (!isPermanentAuthError(err)) {
return false
}
if (err.code === 'NOT_AUTHENTICATED' && useAuthStore().currentUser) {
return false
}
return true
}
// The workspace selection itself is invalid (revoked or deleted), so
// abandoning it can help — unlike a plain auth failure.
function isWorkspaceSelectionInvalid(
err: unknown
): err is WorkspaceAuthError {
return (
err instanceof WorkspaceAuthError &&
(err.code === 'ACCESS_DENIED' || err.code === 'WORKSPACE_NOT_FOUND')
)
}
function startRecoveryCooldown(): void {
recoveryCooldownUntil = Date.now() + RECOVERY_COOLDOWN_MS
}
function handleRecoveryFailure(
err: unknown,
failedWorkspaceId?: string
): void {
if (isPermanentRecoveryFailure(err)) {
const hadContext = currentWorkspace.value !== null
clearWorkspaceContext()
if (hadContext) {
surfacePermanentAuthError(err)
}
if (failedWorkspaceId && isWorkspaceSelectionInvalid(err)) {
useTeamWorkspaceStore().forgetRevokedActiveWorkspace(failedWorkspaceId)
}
}
startRecoveryCooldown()
console.warn('Workspace auth recovery failed:', err)
}
/**
* Resolve a valid workspace token, minting one if needed. Coalesces a burst of
* callers onto a single in-flight mint, backs off after failure, and returns
* null so callers fail closed rather than downgrade to the personal identity.
*/
async function ensureWorkspaceToken(
preferredWorkspaceId?: string
): Promise<string | null> {
if (!flags.teamWorkspacesEnabled) {
return null
}
const targetWorkspaceId = preferredWorkspaceId ?? currentWorkspace.value?.id
while (true) {
if (hasValidTokenForWorkspace(targetWorkspaceId)) {
return workspaceToken.value
}
// Join any in-flight mint and re-check rather than launching our own.
if (inFlightSwitchPromise) {
await inFlightSwitchPromise.catch(() => {})
continue
}
if (!targetWorkspaceId || Date.now() < recoveryCooldownUntil) {
return null
}
try {
await switchWorkspace(targetWorkspaceId)
} catch (err) {
handleRecoveryFailure(err, targetWorkspaceId)
return null
}
if (hasValidTokenForWorkspace(targetWorkspaceId)) {
return workspaceToken.value
}
// Resolved without a usable token; back off like a failure and fail closed.
startRecoveryCooldown()
return null
}
}
async function ensureWorkspaceAuthHeader(
preferredWorkspaceId?: string
): Promise<AuthHeader | null> {
const token = await ensureWorkspaceToken(preferredWorkspaceId)
return token ? { Authorization: `Bearer ${token}` } : null
}
async function refreshToken(): Promise<void> {
if (!currentWorkspace.value) {
return
@@ -457,6 +581,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
if (!isStaleWorkspaceRequest(capturedRequestId)) {
console.error('Workspace access revoked or auth invalid:', err)
clearWorkspaceContext()
if (isWorkspaceSelectionInvalid(err)) {
useTeamWorkspaceStore().forgetRevokedActiveWorkspace(workspaceId)
}
}
return
}
@@ -500,6 +627,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
//
// A parallel mint/refresh lifecycle that writes to the dormant `unifiedToken`
// slot. The legacy switchWorkspace/refreshToken machinery above is untouched.
//
// TODO(unified): call forgetRevokedActiveWorkspace on
// ACCESS_DENIED/WORKSPACE_NOT_FOUND here too, with the PR-3 consumer flip.
// Mint body the unified session re-mints against: `{}` = personal (resolved
// server-side from the Firebase identity), `{ workspace_id }` = explicit.
@@ -663,6 +793,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
workspaceToken.value = null
workspaceTokenExpiresAt.value = null
scheduledRefreshRetryCount = 0
recoveryCooldownUntil = 0
// refreshRequestId bump above aborts any in-flight switch before it commits.
inFlightSwitchPromise = null
error.value = null
clearSessionStorage()
clearUnifiedContext()
@@ -688,6 +821,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
mintAtLogin,
remintUnifiedOnce,
getWorkspaceAuthHeader,
ensureWorkspaceAuthHeader,
ensureWorkspaceToken,
getWorkspaceToken,
clearWorkspaceContext
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, useAttrs } from 'vue'
import { useTextFileContent } from '@/composables/useTextFileContent'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
@@ -23,6 +24,9 @@ const mediaType = computed(() => getMediaType(output))
const outputLabel = computed(
() => output.display_name?.trim() || output.filename
)
const { textContent } = useTextFileContent(() =>
mediaType.value === 'text' ? output : undefined
)
</script>
<template>
<template v-if="mediaType === 'images' || mediaType === 'video'">
@@ -60,7 +64,7 @@ const outputLabel = computed(
attrs.class as string
)
"
v-text="output.content"
v-text="textContent"
/>
<Preview3d
v-else-if="mediaType === '3d'"

View File

@@ -30,5 +30,6 @@ export function getMediaType(output?: ResultItemImpl) {
if (!output) return ''
if (output.isVideo) return 'video'
if (output.isImage) return 'images'
if (output.isText) return 'text'
return output.mediaType
}

View File

@@ -0,0 +1,148 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import type * as VueI18n from 'vue-i18n'
import type * as LitegraphUtil from '@/utils/litegraphUtil'
import WidgetTextPreview from './WidgetTextPreview.vue'
const GRAPH_ID = 'graph-1'
const NODE_ID = toNodeId('7')
const LOCATOR = 'loc-1'
const { downloadFileMock, copyMock } = vi.hoisted(() => ({
downloadFileMock: vi.fn(),
copyMock: vi.fn()
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: downloadFileMock
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({ copyToClipboard: copyMock })
}))
vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
...(await importOriginal<typeof LitegraphUtil>()),
resolveNode: () => ({})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ nodeToNodeLocatorId: () => LOCATOR })
}))
interface SavedFile {
filename: string
subfolder?: string
type?: 'input' | 'output' | 'temp'
}
function renderPreview(
text: string,
opts: { markdown?: boolean; file?: SavedFile } = {}
) {
const pinia = createPinia()
setActivePinia(pinia)
const canvasStore = useCanvasStore()
canvasStore.canvas = fromPartial({ graph: { rootGraph: { id: GRAPH_ID } } })
if (opts.markdown !== undefined) {
useWidgetValueStore().registerWidget<boolean>(
widgetId(GRAPH_ID, NODE_ID, 'preview_mode'),
fromPartial({ type: 'boolean', value: opts.markdown, options: {} })
)
}
if (opts.file) {
useNodeOutputStore().nodeOutputs[LOCATOR] = fromPartial<
NodeOutputWith<{ files?: ResultItem[] }>
>({ files: [opts.file] })
}
return render(WidgetTextPreview, {
props: {
widget: fromPartial<SimplifiedWidget<string>>({
name: 'preview_text',
type: 'textPreview',
options: {}
}),
nodeId: NODE_ID,
modelValue: text
},
global: { plugins: [pinia], mocks: { $t: (key: string) => key } }
})
}
describe('WidgetTextPreview', () => {
beforeEach(() => {
downloadFileMock.mockClear()
copyMock.mockClear()
})
it('renders plaintext in a textarea by default', () => {
renderPreview('# not rendered')
expect(screen.getByRole('textbox')).toHaveValue('# not rendered')
expect(screen.queryByRole('heading')).not.toBeInTheDocument()
})
it('renders rich markdown when preview_mode is on', () => {
renderPreview('# Title', { markdown: true })
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Title')
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('copies the raw text when the copy button is clicked', async () => {
renderPreview('hello world')
await userEvent.click(
screen.getByRole('button', { name: 'g.copyToClipboard' })
)
expect(copyMock).toHaveBeenCalledWith('hello world')
})
it('has no download button without a saved file output', () => {
renderPreview('hello')
expect(
screen.queryByRole('button', { name: 'g.download' })
).not.toBeInTheDocument()
})
it('downloads the saved file via the /view url when clicked', async () => {
renderPreview('hello', {
file: { filename: 'result_00001.txt', subfolder: 'sub', type: 'output' }
})
await userEvent.click(screen.getByRole('button', { name: 'g.download' }))
expect(downloadFileMock).toHaveBeenCalledTimes(1)
const [url, filename] = downloadFileMock.mock.calls[0]
expect(url).toContain('/view?')
expect(url).toContain('filename=result_00001.txt')
expect(url).toContain('subfolder=sub')
expect(url).toContain('type=output')
expect(filename).toBe('result_00001.txt')
})
})

View File

@@ -0,0 +1,167 @@
<template>
<div class="widget-text-preview group relative w-full">
<div
class="invisible absolute top-1.5 right-1.5 z-10 flex gap-1 group-focus-within:visible group-hover:visible"
>
<Button
variant="textonly"
size="icon"
class="hover:bg-base-foreground/10"
:title="$t('g.copyToClipboard')"
:aria-label="$t('g.copyToClipboard')"
@click="handleCopy"
@pointerdown.capture.stop
>
<i class="icon-[lucide--copy] size-4 text-component-node-foreground" />
</Button>
<Button
v-if="downloadUrl"
variant="textonly"
size="icon"
class="hover:bg-base-foreground/10"
:title="$t('g.download')"
:aria-label="$t('g.download')"
@click="handleDownload"
@pointerdown.capture.stop
>
<i
class="icon-[lucide--download] size-4 text-component-node-foreground"
/>
</Button>
</div>
<div
v-if="showMarkdown"
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
data-capture-wheel="true"
role="textarea"
:aria-label="widget.name"
aria-readonly="true"
v-html="renderedMarkdown"
/>
<Textarea
v-else
:model-value="modelValue"
readonly
:aria-label="widget.name"
:class="
cn(
WidgetInputBaseClass,
'size-full resize-none text-(length:--comfy-textarea-font-size) leading-normal',
// Keep overflow stable so the scrollbar-gutter (from the base
// Textarea) stays reserved; a hover overflow toggle would reflow the
// content when the gutter appears.
'overflow-y-auto'
)
"
data-capture-wheel="true"
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { downloadFile } from '@/base/common/downloadUtil'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNode } from '@/utils/litegraphUtil'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { WidgetInputBaseClass } from './layout'
type SaveTextOutput = NodeOutputWith<{
files?: ResultItem[]
}>
const MODE_WIDGET_NAME = 'preview_mode'
const { widget, nodeId } = defineProps<{
widget: SimplifiedWidget<string>
nodeId?: NodeId
}>()
const modelValue = defineModel<string>({ default: '' })
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const canvasStore = useCanvasStore()
const widgetValueStore = useWidgetValueStore()
const nodeOutputStore = useNodeOutputStore()
const { nodeToNodeLocatorId } = useWorkflowStore()
const localNodeId = computed(() =>
nodeId === undefined ? null : stripGraphPrefix(nodeId)
)
const showMarkdown = computed<boolean>(() => {
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId || localNodeId.value === null) return false
return (
widgetValueStore.getWidget(
widgetId(graphId, localNodeId.value, MODE_WIDGET_NAME)
)?.value === true
)
})
const renderedMarkdown = computed(() =>
showMarkdown.value ? renderMarkdownToHtml(modelValue.value || '') : ''
)
const savedFile = computed(() => {
if (nodeId === undefined) return undefined
const node = resolveNode(nodeId)
if (!node) return undefined
const outputs = nodeOutputStore.nodeOutputs[nodeToNodeLocatorId(node)] as
| SaveTextOutput
| undefined
return outputs?.files?.[0]
})
const downloadUrl = computed(() => {
const file = savedFile.value
if (!file?.filename) return undefined
const params = new URLSearchParams({
filename: file.filename,
subfolder: file.subfolder ?? '',
type: file.type ?? 'output'
})
return api.apiURL(`/view?${params}`)
})
function handleCopy() {
void copyToClipboard(modelValue.value)
}
function handleDownload() {
if (!downloadUrl.value) return
try {
downloadFile(downloadUrl.value, savedFile.value?.filename)
} catch {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile')
})
}
}
</script>

View File

@@ -39,6 +39,9 @@ const WidgetGalleria = defineAsyncComponent(
const WidgetMarkdown = defineAsyncComponent(
() => import('../components/WidgetMarkdown.vue')
)
const WidgetTextPreview = defineAsyncComponent(
() => import('../components/WidgetTextPreview.vue')
)
const WidgetLegacy = defineAsyncComponent(
() => import('../components/WidgetLegacy.vue')
)
@@ -160,6 +163,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
[
'textPreview',
{
component: WidgetTextPreview,
aliases: ['TEXT_PREVIEW'],
essential: false
}
],
['legacy', { component: WidgetLegacy, aliases: [], essential: true }],
[
'audiorecord',
@@ -275,6 +286,7 @@ export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
const EXPANDING_TYPES = [
'textarea',
'markdown',
'textPreview',
'load3D',
'load3DAdvanced',
'curve',

View File

@@ -5,6 +5,31 @@ import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
describe('NodeDef Migration', () => {
it('preserves interactive display definitions', () => {
const nodeDef = {
name: 'Interactive',
display_name: 'Interactive',
category: 'Testing',
python_module: 'test_module',
description: '',
output_node: true,
interactive_ui: [
{
id: 'stream',
kind: 'video_stream',
views: [
{ id: 'source', role: 'local_source', label: 'Webcam' },
{ id: 'result', role: 'remote_output', label: 'Inverted' }
]
}
]
} as ComfyNodeDefV1
expect(transformNodeDefV1ToV2(nodeDef).interactive_ui).toEqual(
nodeDef.interactive_ui
)
})
it('should transform a plain object to V2 format', () => {
const plainObject = {
required: {

View File

@@ -81,6 +81,7 @@ export function transformNodeDefV1ToV2(
category: nodeDefV1.category,
output_node: nodeDefV1.output_node,
python_module: nodeDefV1.python_module,
interactive_ui: nodeDefV1.interactive_ui,
deprecated: nodeDefV1.deprecated,
experimental: nodeDefV1.experimental
}

View File

@@ -6,6 +6,7 @@ import {
zColorStop,
zComboInputOptions,
zFloatInputOptions,
zInteractiveUi,
zIntInputOptions,
zStringInputOptions
} from '@/schemas/nodeDefSchema'
@@ -242,7 +243,8 @@ export const zComfyNodeDef = z.object({
deprecated: z.boolean().optional(),
experimental: z.boolean().optional(),
dev_only: z.boolean().optional(),
api_node: z.boolean().optional()
api_node: z.boolean().optional(),
interactive_ui: zInteractiveUi.optional()
})
// Export types

View File

@@ -37,9 +37,19 @@ describe('API Feature Flags', () => {
vi.stubGlobal('WebSocket', function (this: WebSocket) {
Object.assign(this, mockWebSocket)
})
Reflect.set(WebSocket, 'OPEN', 1)
// Reset API state
for (const event of Object.keys(wsEventHandlers)) {
delete wsEventHandlers[event]
}
api.socket = null
api.clientId = undefined
Reflect.set(api, 'socketGeneration', 0)
Reflect.set(api, 'confirmedSocket', null)
Reflect.set(api, 'confirmedClientId', undefined)
api.serverFeatureFlags.value = {}
window.name = ''
// Mock getClientFeatureFlags to return test feature flags
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
@@ -147,6 +157,60 @@ describe('API Feature Flags', () => {
// Server features should remain empty
expect(api.serverFeatureFlags.value).toEqual({})
})
it('queues an interactive prompt with the SID confirmed by the current socket', async () => {
const fetchSpy = vi
.spyOn(api, 'fetchApi')
.mockResolvedValue(
new Response(JSON.stringify({ prompt_id: 'job-1' }), { status: 200 })
)
api.init()
await Promise.resolve()
const oldSocketHandlers = { ...wsEventHandlers }
oldSocketHandlers.open(new Event('open'))
oldSocketHandlers.message({
data: JSON.stringify({
type: 'status',
data: { status: {}, sid: 'old-sid' }
})
})
oldSocketHandlers.close(new CloseEvent('close'))
const queuePromise = api.queuePrompt(
0,
{ output: {}, workflow: {} as never },
{ requireConnectedClient: true }
)
await Promise.resolve()
expect(fetchSpy).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(300)
const replacementSocketHandlers = { ...wsEventHandlers }
replacementSocketHandlers.open(new Event('open'))
await vi.advanceTimersByTimeAsync(100)
expect(fetchSpy).not.toHaveBeenCalled()
replacementSocketHandlers.message({
data: JSON.stringify({
type: 'status',
data: { status: {}, sid: 'replacement-sid' }
})
})
oldSocketHandlers.message({
data: JSON.stringify({
type: 'status',
data: { status: {}, sid: 'stale-sid' }
})
})
oldSocketHandlers.close(new CloseEvent('close'))
await vi.advanceTimersByTimeAsync(50)
await expect(queuePromise).resolves.toEqual({ prompt_id: 'job-1' })
expect(fetchSpy).toHaveBeenCalledTimes(1)
const request = fetchSpy.mock.calls[0][1]
expect(JSON.parse(request?.body as string).client_id).toBe(
'replacement-sid'
)
})
})
describe('Feature checking methods', () => {

View File

@@ -4,6 +4,16 @@ import { storeToRefs } from 'pinia'
import { get } from 'es-toolkit/compat'
import { trimEnd } from 'es-toolkit'
import { ref } from 'vue'
import {
INTERACTION_MEDIA,
decodeInteractionMedia,
encodeInteractionMedia,
parseInteractionControl
} from '@/services/interactionProtocol'
import type {
InteractionControl,
InteractionMediaMetadata
} from '@/services/interactionProtocol'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
import {
@@ -142,6 +152,11 @@ interface QueuePromptOptions {
* 'default' uses the server's CLI setting and is not sent to backend.
*/
previewMethod?: PreviewMethod
/**
* Wait for the current WebSocket connection to receive its server session ID
* before submitting a prompt that requires a live browser session.
*/
requireConnectedClient?: boolean
}
/** Dictionary of Frontend-generated API calls */
@@ -159,6 +174,11 @@ export type PromptQueuedEventPayload = FrontendApiCalls['promptQueued']
/** Dictionary of calls originating from ComfyUI core */
interface BackendApiCalls {
interaction: InteractionControl
interaction_media: {
metadata: InteractionMediaMetadata
blob: Blob
}
progress: ProgressWsMessage
executing: ExecutingWsMessage
executed: ExecutedWsMessage
@@ -346,6 +366,9 @@ export class ComfyApi extends EventTarget {
*/
user: string
socket: WebSocket | null = null
private socketGeneration = 0
private confirmedSocket: WebSocket | null = null
private confirmedClientId?: string
/**
* Cache Firebase auth store composable function.
@@ -647,6 +670,7 @@ export class ComfyApi extends EventTarget {
if (this.socket) {
return
}
const generation = ++this.socketGeneration
let opened = false
let existingSession = window.name
@@ -681,14 +705,19 @@ export class ComfyApi extends EventTarget {
const query = params.toString()
const wsUrl = query ? `${baseUrl}?${query}` : baseUrl
this.socket = new WebSocket(wsUrl)
this.socket.binaryType = 'arraybuffer'
if (generation !== this.socketGeneration || this.socket) return
const socket = new WebSocket(wsUrl)
this.socket = socket
this.confirmedSocket = null
this.confirmedClientId = undefined
socket.binaryType = 'arraybuffer'
this.socket.addEventListener('open', () => {
socket.addEventListener('open', () => {
if (this.socket !== socket) return
opened = true
// Send feature flags as the first message
this.socket!.send(
socket.send(
JSON.stringify({
type: 'feature_flags',
data: this.getClientFeatureFlags()
@@ -700,30 +729,45 @@ export class ComfyApi extends EventTarget {
}
})
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
socket.addEventListener('error', () => {
socket.close()
if (!isReconnect && !opened) {
this._pollQueue()
}
})
this.socket.addEventListener('close', () => {
socket.addEventListener('close', () => {
if (this.socket !== socket) return
this.confirmedSocket = null
this.confirmedClientId = undefined
setTimeout(async () => {
if (this.socket !== socket) return
this.socket = null
await this.createSocket(true)
}, 300)
if (opened) {
this.serverFeatureFlags.value = {}
this.dispatchCustomEvent('status', null)
this.dispatchCustomEvent('reconnecting')
}
})
this.socket.addEventListener('message', (event) => {
socket.addEventListener('message', (event) => {
if (this.socket !== socket) return
try {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data)
const eventType = view.getUint32(0)
if (eventType === INTERACTION_MEDIA) {
const { metadata, media } = decodeInteractionMedia(event.data)
this.dispatchCustomEvent('interaction_media', {
metadata,
blob: new Blob([media], { type: metadata.mime })
})
return
}
let imageMime
switch (eventType) {
case 3: {
@@ -820,6 +864,8 @@ export class ComfyApi extends EventTarget {
if (msg.data.sid) {
const clientId = msg.data.sid
this.clientId = clientId
this.confirmedSocket = socket
this.confirmedClientId = clientId
window.name = clientId // use window name so it isn't reused when duplicating tabs
sessionStorage.setItem('clientId', clientId) // store in session storage so duplicate tab can load correct workflow
}
@@ -855,6 +901,12 @@ export class ComfyApi extends EventTarget {
)
this.dispatchCustomEvent('feature_flags', msg.data)
break
case 'interaction':
this.dispatchCustomEvent(
'interaction',
parseInteractionControl(msg.data)
)
break
default:
if (this._registered.has(msg.type)) {
// Fallback for custom types - calls super direct.
@@ -880,6 +932,41 @@ export class ComfyApi extends EventTarget {
this.createSocket()
}
sendInteractionControl(
data: Omit<InteractionControl, 'op'> & {
op: 'ready' | 'stop' | 'cancel'
width?: number
height?: number
fps?: number
mime?: 'image/jpeg'
}
): boolean {
if (this.socket?.readyState !== WebSocket.OPEN) return false
try {
this.socket.send(JSON.stringify({ type: 'interaction', data }))
return true
} catch {
return false
}
}
sendInteractionMedia(
metadata: InteractionMediaMetadata,
media: ArrayBuffer
): boolean {
if (
this.socket?.readyState !== WebSocket.OPEN ||
this.socket.bufferedAmount > 2 * 1024 * 1024
)
return false
try {
this.socket.send(encodeInteractionMedia(metadata, media))
return true
} catch {
return false
}
}
/**
* Gets a list of extension urls
*/
@@ -955,9 +1042,20 @@ export class ComfyApi extends EventTarget {
options?: QueuePromptOptions
): Promise<PromptResponse> {
const { output: prompt, workflow } = data
let clientId = this.clientId ?? ''
if (options?.requireConnectedClient) {
const confirmedClientId = await this.waitForSocketSession()
if (!confirmedClientId) {
throw new Error(
'The browser connection is still reconnecting. Wait a moment and try again.'
)
}
clientId = confirmedClientId
}
const body: QueuePromptRequestBody = {
client_id: this.clientId ?? '', // TODO: Unify clientId access
client_id: clientId,
prompt,
...(options?.partialExecutionTargets && {
partial_execution_targets: options.partialExecutionTargets
@@ -1008,6 +1106,22 @@ export class ComfyApi extends EventTarget {
return await res.json()
}
private async waitForSocketSession(
timeout = 5000
): Promise<string | undefined> {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
if (
this.socket &&
this.socket === this.confirmedSocket &&
this.socket.readyState === WebSocket.OPEN
) {
return this.confirmedClientId
}
await new Promise((resolve) => window.setTimeout(resolve, 50))
}
}
/**
* Gets the list of assets and models referenced by a prompt that would
* need user consent before sharing.

View File

@@ -1667,9 +1667,15 @@ export class ComfyApp {
try {
api.authToken = comfyOrgAuthToken
api.apiKey = comfyOrgApiKey ?? undefined
const requiresConnectedClient = Object.values(p.output).some(
(node) =>
!!useNodeDefStore().nodeDefsByName[node.class_type]
?.interactive_ui?.length
)
const res = await api.queuePrompt(number, p, {
partialExecutionTargets: queueNodeIds,
previewMethod
previewMethod,
requireConnectedClient: requiresConnectedClient
})
delete api.authToken
delete api.apiKey

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
decodeInteractionMedia,
encodeInteractionMedia,
parseInteractionControl
} from '@/services/interactionProtocol'
describe('interaction media protocol', () => {
it('round trips metadata and binary media', () => {
const metadata = {
v: 1 as const,
interaction_id: 'session',
prompt_id: 'prompt',
channel: 'source' as const,
seq: 3,
capture_ts_ms: 42,
mime: 'image/jpeg' as const
}
const media = new Uint8Array([0, 1, 255]).buffer
const decoded = decodeInteractionMedia(
encodeInteractionMedia(metadata, media)
)
expect(decoded.metadata).toEqual(metadata)
expect(new Uint8Array(decoded.media)).toEqual(new Uint8Array(media))
})
it('rejects malformed frame lengths', () => {
const frame = new ArrayBuffer(9)
const view = new DataView(frame)
view.setUint32(0, 5)
view.setUint32(4, 10)
expect(() => decodeInteractionMedia(frame)).toThrow(
'Invalid interaction metadata length'
)
})
it('retains interaction routing fields', () => {
expect(
parseInteractionControl({
v: 1,
op: 'open',
interaction_id: 'session',
prompt_id: 'prompt',
display_node_id: '12',
group_id: 'video',
kind: 'video_stream'
})
).toMatchObject({
prompt_id: 'prompt',
display_node_id: '12',
group_id: 'video',
kind: 'video_stream'
})
})
it('accepts a null list index from non-list node execution', () => {
expect(
parseInteractionControl({
v: 1,
op: 'open',
interaction_id: 'session',
list_index: null
}).list_index
).toBeNull()
})
it.for([
{ v: 1, op: 'open', interaction_id: '' },
{ v: 1, op: 'open', interaction_id: 'session', list_index: -1 },
{
v: 1,
op: 'open',
interaction_id: 'session',
limits: { mime_types: [42] }
},
{
v: 1,
op: 'open',
interaction_id: 'session',
limits: { max_frame_bytes: 0 }
}
])('rejects malformed control messages', (control) => {
expect(() => parseInteractionControl(control)).toThrow()
})
})

View File

@@ -0,0 +1,153 @@
export const INTERACTION_MEDIA = 5
const MAX_INTERACTION_METADATA_BYTES = 4096
export const MAX_INTERACTION_MEDIA_BYTES = 8 * 1024 * 1024
export type InteractionControl = {
v: 1
op: 'open' | 'resume' | 'credit' | 'closed' | 'error'
interaction_id: string
prompt_id?: string
node_id?: string
display_node_id?: string
group_id?: string
list_index?: number | null
kind?: string
limits?: {
mime_types?: string[]
max_frame_bytes?: number
max_inflight?: number
}
count?: number
reason?: string
message?: string
}
export type InteractionMediaMetadata = {
v: 1
interaction_id: string
prompt_id?: string
channel: 'source' | 'result'
seq: number
capture_ts_ms: number
mime: 'image/jpeg'
input_seq?: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function parseInteractionControl(value: unknown): InteractionControl {
if (
!isRecord(value) ||
value.v !== 1 ||
typeof value.op !== 'string' ||
typeof value.interaction_id !== 'string' ||
value.interaction_id.length === 0 ||
(value.prompt_id !== undefined && typeof value.prompt_id !== 'string') ||
(value.node_id !== undefined && typeof value.node_id !== 'string') ||
(value.display_node_id !== undefined &&
typeof value.display_node_id !== 'string') ||
(value.group_id !== undefined && typeof value.group_id !== 'string') ||
(value.kind !== undefined && typeof value.kind !== 'string') ||
(value.list_index !== undefined &&
value.list_index !== null &&
(typeof value.list_index !== 'number' ||
!Number.isSafeInteger(value.list_index) ||
value.list_index < 0)) ||
(value.count !== undefined &&
(typeof value.count !== 'number' ||
!Number.isSafeInteger(value.count) ||
value.count < 1)) ||
(value.reason !== undefined && typeof value.reason !== 'string') ||
(value.message !== undefined && typeof value.message !== 'string')
)
throw new Error('Invalid interaction control')
if (!['open', 'resume', 'credit', 'closed', 'error'].includes(value.op))
throw new Error('Invalid interaction operation')
if (value.limits !== undefined) {
if (!isRecord(value.limits)) throw new Error('Invalid interaction limits')
const {
mime_types: mimeTypes,
max_frame_bytes: maxFrameBytes,
max_inflight: maxInflight
} = value.limits
if (
(mimeTypes !== undefined &&
(!Array.isArray(mimeTypes) ||
mimeTypes.some((mime) => typeof mime !== 'string'))) ||
(maxFrameBytes !== undefined &&
(typeof maxFrameBytes !== 'number' ||
!Number.isSafeInteger(maxFrameBytes) ||
maxFrameBytes < 1)) ||
(maxInflight !== undefined &&
(typeof maxInflight !== 'number' ||
!Number.isSafeInteger(maxInflight) ||
maxInflight < 1))
)
throw new Error('Invalid interaction limits')
}
return value as InteractionControl
}
export function encodeInteractionMedia(
metadata: InteractionMediaMetadata,
media: ArrayBuffer
): ArrayBuffer {
const encoded = new TextEncoder().encode(JSON.stringify(metadata))
if (encoded.length > MAX_INTERACTION_METADATA_BYTES)
throw new Error('Interaction metadata is too large')
if (media.byteLength > MAX_INTERACTION_MEDIA_BYTES)
throw new Error('Interaction media is too large')
const result = new Uint8Array(8 + encoded.length + media.byteLength)
new DataView(result.buffer).setUint32(0, INTERACTION_MEDIA)
new DataView(result.buffer).setUint32(4, encoded.length)
result.set(encoded, 8)
result.set(new Uint8Array(media), 8 + encoded.length)
return result.buffer
}
export function decodeInteractionMedia(data: ArrayBuffer): {
metadata: InteractionMediaMetadata
media: ArrayBuffer
} {
if (data.byteLength < 9) throw new Error('Truncated interaction media')
const view = new DataView(data)
if (view.getUint32(0) !== INTERACTION_MEDIA)
throw new Error('Invalid interaction media type')
const length = view.getUint32(4)
if (
length === 0 ||
length > MAX_INTERACTION_METADATA_BYTES ||
8 + length >= data.byteLength
)
throw new Error('Invalid interaction metadata length')
if (data.byteLength - 8 - length > MAX_INTERACTION_MEDIA_BYTES)
throw new Error('Interaction media is too large')
const value: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(data.slice(8, 8 + length))
)
if (
!isRecord(value) ||
value.v !== 1 ||
typeof value.interaction_id !== 'string' ||
value.interaction_id.length === 0 ||
(value.prompt_id !== undefined && typeof value.prompt_id !== 'string') ||
!['source', 'result'].includes(String(value.channel)) ||
typeof value.seq !== 'number' ||
!Number.isSafeInteger(value.seq) ||
value.seq < 0 ||
typeof value.capture_ts_ms !== 'number' ||
!Number.isFinite(value.capture_ts_ms) ||
(value.input_seq !== undefined &&
(typeof value.input_seq !== 'number' ||
!Number.isSafeInteger(value.input_seq) ||
value.input_seq < 0)) ||
value.mime !== 'image/jpeg'
)
throw new Error('Invalid interaction media metadata')
return {
metadata: value as InteractionMediaMetadata,
media: data.slice(8 + length)
}
}

View File

@@ -0,0 +1,262 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { addInteractiveViews } from '@/services/interactiveViewService'
const mocks = vi.hoisted(() => {
const target = new EventTarget()
return {
target,
locatedNode: undefined as LGraphNode | undefined,
controls: [] as object[],
serverSupportsFeature: vi.fn(() => true),
sendInteractionMedia: vi.fn(() => true)
}
})
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (type: string, listener: EventListener) =>
mocks.target.addEventListener(type, listener),
removeEventListener: (type: string, listener: EventListener) =>
mocks.target.removeEventListener(type, listener),
serverSupportsFeature: mocks.serverSupportsFeature,
sendInteractionControl: (control: object) => {
mocks.controls.push(control)
return true
},
sendInteractionMedia: mocks.sendInteractionMedia
}
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: () => 'locator',
getNodeByLocatorId: () => mocks.locatedNode
}))
const nodeDef = {
interactive_ui: [
{
id: 'video',
kind: 'video_stream',
views: [
{ id: 'source', role: 'local_source', label: 'Webcam' },
{ id: 'result', role: 'remote_output', label: 'Inverted' }
]
}
]
} as ComfyNodeDef
function emit(type: string, detail?: object) {
mocks.target.dispatchEvent(new CustomEvent(type, { detail }))
}
function createNode() {
let element: HTMLElement | undefined
const node = {
id: 12,
addDOMWidget: vi.fn(
(_name: string, _type: string, widgetElement: HTMLElement) => {
element = widgetElement
return { serialize: true }
}
)
} as unknown as LGraphNode
mocks.locatedNode = node
addInteractiveViews(node, nodeDef)
return { node, element: element! }
}
async function startWebcam(element: HTMLElement) {
const track = {
stop: vi.fn(),
addEventListener: vi.fn()
}
const stream = {
getTracks: () => [track],
getVideoTracks: () => [track]
}
vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValueOnce(
stream as unknown as MediaStream
)
element.querySelector('button')!.click()
await vi.waitFor(() =>
expect(element.textContent).toContain(
'Webcam ready. Queue the node to stream.'
)
)
}
beforeEach(() => {
mocks.controls.length = 0
mocks.serverSupportsFeature.mockReturnValue(true)
mocks.sendInteractionMedia.mockClear()
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: vi.fn() }
})
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
configurable: true,
writable: true,
value: null
})
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('interactive view sessions', () => {
it('does not request camera access when interactions are unavailable', () => {
mocks.serverSupportsFeature.mockReturnValue(false)
const { node, element } = createNode()
const start = element.querySelector('button')!
expect(start.hasAttribute('disabled')).toBe(true)
start.click()
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled()
node.onRemoved?.()
})
it('keeps prompt routing stable and resends cancellation after reconnect', async () => {
const { node, element } = createNode()
await startWebcam(element)
const createBitmap = vi.fn().mockResolvedValue({
width: 1,
height: 1,
close: vi.fn()
})
vi.stubGlobal('createImageBitmap', createBitmap)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn()
} as unknown as GPUCanvasContext)
emit('interaction', {
v: 1,
op: 'open',
interaction_id: 'interaction-a',
prompt_id: 'prompt-a',
display_node_id: '12',
group_id: 'video',
limits: { max_frame_bytes: 10 }
})
expect(mocks.controls).toContainEqual(
expect.objectContaining({ op: 'ready', prompt_id: 'prompt-a' })
)
emit('interaction_media', {
metadata: {
v: 1,
interaction_id: 'interaction-a',
channel: 'result',
seq: 0,
capture_ts_ms: 1,
mime: 'image/jpeg'
},
blob: new Blob([new Uint8Array(1)], { type: 'image/jpeg' })
})
await vi.waitFor(() => expect(createBitmap).toHaveBeenCalledOnce())
const controlCount = mocks.controls.length
emit('interaction', {
v: 1,
op: 'resume',
interaction_id: 'interaction-a',
prompt_id: 'prompt-b'
})
expect(mocks.controls).toHaveLength(controlCount)
emit('interaction', {
v: 1,
op: 'resume',
interaction_id: 'interaction-a',
prompt_id: 'prompt-a'
})
emit('interaction_media', {
metadata: {
v: 1,
interaction_id: 'interaction-a',
channel: 'result',
seq: 1,
capture_ts_ms: 2,
mime: 'image/jpeg'
},
blob: new Blob([new Uint8Array(11)], { type: 'image/jpeg' })
})
emit('interaction_media', {
metadata: {
v: 1,
interaction_id: 'interaction-a',
channel: 'result',
seq: 0,
capture_ts_ms: 1,
mime: 'image/jpeg'
},
blob: new Blob([new Uint8Array(1)], { type: 'image/jpeg' })
})
await Promise.resolve()
expect(createBitmap).toHaveBeenCalledOnce()
vi.useFakeTimers()
emit('reconnecting')
await vi.advanceTimersByTimeAsync(6500)
expect(mocks.controls.at(-1)).toMatchObject({
op: 'cancel',
prompt_id: 'prompt-a'
})
const cancellationCount = mocks.controls.length
emit('reconnected')
expect(mocks.controls).toHaveLength(cancellationCount)
emit('feature_flags')
expect(mocks.controls.at(-1)).toMatchObject({
op: 'cancel',
prompt_id: 'prompt-a'
})
node.onRemoved?.()
await vi.runAllTimersAsync()
})
it('cancels an active stream when reconnect negotiation drops support', async () => {
const { node, element } = createNode()
await startWebcam(element)
emit('interaction', {
v: 1,
op: 'open',
interaction_id: 'interaction-b',
prompt_id: 'prompt-b',
display_node_id: '12',
group_id: 'video'
})
emit('reconnecting')
emit('reconnected')
mocks.serverSupportsFeature.mockReturnValue(false)
emit('feature_flags')
expect(mocks.controls.at(-1)).toMatchObject({
op: 'cancel',
interaction_id: 'interaction-b',
prompt_id: 'prompt-b'
})
expect(element.textContent).toContain(
'Interactive streaming is unavailable.'
)
emit('interaction', {
v: 1,
op: 'closed',
interaction_id: 'interaction-b',
prompt_id: 'prompt-b'
})
node.onRemoved?.()
})
})

View File

@@ -0,0 +1,573 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import type {
InteractionControl,
InteractionMediaMetadata
} from '@/services/interactionProtocol'
import { MAX_INTERACTION_MEDIA_BYTES } from '@/services/interactionProtocol'
import {
executionIdToNodeLocatorId,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
type Session = {
node: LGraphNode
definitionId: string
element: HTMLElement
video: HTMLVideoElement
canvas: HTMLCanvasElement
captureCanvas: HTMLCanvasElement
status: HTMLElement
stream?: MediaStream
interactionId?: string
promptId?: string
seq: number
capturing: boolean
starting: boolean
startGeneration: number
lastResultSeq: number
decodingResult: boolean
pendingResult?: {
metadata: InteractionMediaMetadata
blob: Blob
}
nextCaptureAt: number
captureTimer?: number
pendingTerminalOp?: 'stop' | 'cancel'
reconnectTimer?: number
cleanupTimer?: number
maxFrameBytes: number
disposed: boolean
}
type InteractiveDefinition = NonNullable<ComfyNodeDef['interactive_ui']>[number]
const sessions = new Set<Session>()
const reconnectGraceMs = 6500
const terminalCleanupMs = 30_000
function clearTimer(timer: number | undefined) {
if (timer !== undefined) window.clearTimeout(timer)
}
function stopCapture(session: Session) {
session.stream?.getTracks().forEach((track) => track.stop())
session.startGeneration++
session.starting = false
session.video.srcObject = null
session.stream = undefined
session.capturing = false
clearTimer(session.captureTimer)
session.captureTimer = undefined
}
function sendTerminal(session: Session, op: 'stop' | 'cancel') {
stopCapture(session)
if (!session.interactionId) return
session.pendingTerminalOp = op
api.sendInteractionControl({
v: 1,
op,
interaction_id: session.interactionId,
prompt_id: session.promptId
})
}
function clearSession(session: Session) {
stopCapture(session)
session.interactionId = undefined
session.promptId = undefined
session.pendingTerminalOp = undefined
session.pendingResult = undefined
clearTimer(session.reconnectTimer)
clearTimer(session.cleanupTimer)
session.reconnectTimer = undefined
session.cleanupTimer = undefined
}
function scheduleProtocolCleanup(session: Session) {
clearTimer(session.cleanupTimer)
session.cleanupTimer = window.setTimeout(() => {
clearSession(session)
if (session.disposed) sessions.delete(session)
}, terminalCleanupMs)
}
function stopByUser(session: Session) {
const wasStreaming = !!session.interactionId
sendTerminal(session, 'stop')
if (wasStreaming) {
scheduleProtocolCleanup(session)
session.status.textContent = 'Stream ended.'
} else {
session.status.textContent = 'Webcam stopped.'
}
}
function sendReady(session: Session) {
if (!session.interactionId) return
api.sendInteractionControl({
v: 1,
op: 'ready',
interaction_id: session.interactionId,
prompt_id: session.promptId,
width: 640,
height: 480,
fps: 10,
mime: 'image/jpeg'
})
}
function resendSessionState(session: Session) {
if (!session.interactionId) return
if (session.pendingTerminalOp) {
api.sendInteractionControl({
v: 1,
op: session.pendingTerminalOp,
interaction_id: session.interactionId,
prompt_id: session.promptId
})
return
}
sendReady(session)
}
async function start(session: Session) {
if (
session.stream ||
session.starting ||
session.interactionId ||
session.disposed
)
return
if (!api.serverSupportsFeature('supports_interactions_v1')) {
session.status.textContent = 'Interactive streaming is unavailable.'
return
}
const generation = ++session.startGeneration
session.starting = true
let stream: MediaStream | undefined
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
audio: false
})
if (session.disposed || session.startGeneration !== generation) {
stream.getTracks().forEach((track) => track.stop())
return
}
session.stream = stream
session.video.srcObject = stream
stream.getVideoTracks().forEach((track) => {
track.addEventListener('ended', () => {
if (session.stream !== stream) return
sendTerminal(session, 'stop')
scheduleProtocolCleanup(session)
session.status.textContent = 'Stream ended.'
})
})
await session.video.play()
session.status.textContent = 'Webcam ready. Queue the node to stream.'
} catch {
stream?.getTracks().forEach((track) => track.stop())
if (session.stream === stream) {
session.stream = undefined
session.video.srcObject = null
}
session.status.textContent = 'Allow camera access to start.'
} finally {
if (session.startGeneration === generation) session.starting = false
}
}
function waitForSocket() {
return new Promise((resolve) => window.setTimeout(resolve, 50))
}
async function sendCapturedFrame(
session: Session,
interactionId: string,
blob: Blob,
captureTimestamp: number
) {
const media = await blob.arrayBuffer()
while (
!session.disposed &&
session.interactionId === interactionId &&
session.stream
) {
const metadata: InteractionMediaMetadata = {
v: 1,
interaction_id: interactionId,
prompt_id: session.promptId,
channel: 'source',
seq: session.seq,
capture_ts_ms: captureTimestamp,
mime: 'image/jpeg'
}
if (api.sendInteractionMedia(metadata, media)) {
session.seq++
return
}
await waitForSocket()
}
}
function capture(session: Session, interactionId: string) {
if (
session.capturing ||
session.captureTimer !== undefined ||
!session.stream ||
session.interactionId !== interactionId
)
return
const delay = session.nextCaptureAt - Date.now()
if (delay > 0) {
session.captureTimer = window.setTimeout(() => {
session.captureTimer = undefined
capture(session, interactionId)
}, delay)
return
}
session.nextCaptureAt = Date.now() + 100
session.capturing = true
session.captureCanvas
.getContext('2d')
?.drawImage(session.video, 0, 0, 640, 480)
const captureTimestamp = Date.now()
session.captureCanvas.toBlob(
async (blob) => {
try {
if (!blob) {
sendTerminal(session, 'cancel')
scheduleProtocolCleanup(session)
session.status.textContent = 'Failed to capture webcam frame.'
return
}
if (session.interactionId !== interactionId) return
if (blob.size > session.maxFrameBytes) {
sendTerminal(session, 'cancel')
scheduleProtocolCleanup(session)
session.status.textContent = 'Captured webcam frame is too large.'
return
}
await sendCapturedFrame(session, interactionId, blob, captureTimestamp)
} finally {
session.capturing = false
}
},
'image/jpeg',
0.85
)
}
function matchingSession(control: InteractionControl): Session | undefined {
const active = [...sessions].find(
(session) => session.interactionId === control.interaction_id
)
if (active) return active
if (control.op !== 'open' && control.op !== 'resume') return
const locatorId = control.display_node_id
? executionIdToNodeLocatorId(app.rootGraph, control.display_node_id)
: undefined
const node = locatorId
? getNodeByLocatorId(app.rootGraph, locatorId)
: undefined
return [...sessions].find(
(session) =>
!session.interactionId &&
(session.node === node ||
String(session.node.id) === control.display_node_id) &&
(!control.group_id || session.definitionId === control.group_id)
)
}
api.addEventListener('interaction', (event) => {
const control = event.detail
const session = matchingSession(control)
if (!session) {
if (control.op === 'open' || control.op === 'resume')
api.sendInteractionControl({
v: 1,
op: 'cancel',
interaction_id: control.interaction_id,
prompt_id: control.prompt_id
})
return
}
if (session.promptId !== undefined && session.promptId !== control.prompt_id)
return
if (control.op === 'open' || control.op === 'resume') {
clearTimer(session.reconnectTimer)
session.reconnectTimer = undefined
if (session.pendingTerminalOp) {
resendSessionState(session)
session.status.textContent = 'Stream ended.'
return
}
if (!session.stream) {
api.sendInteractionControl({
v: 1,
op: 'cancel',
interaction_id: control.interaction_id,
prompt_id: control.prompt_id
})
session.status.textContent =
'Start the webcam, then queue the node again.'
return
}
if (
control.limits?.mime_types &&
!control.limits.mime_types.includes('image/jpeg')
) {
api.sendInteractionControl({
v: 1,
op: 'cancel',
interaction_id: control.interaction_id,
prompt_id: control.prompt_id
})
session.status.textContent = 'The stream does not support JPEG frames.'
return
}
const isNewInteraction = session.interactionId === undefined
session.promptId = control.prompt_id
session.interactionId = control.interaction_id
if (isNewInteraction) {
session.lastResultSeq = -1
session.maxFrameBytes = Math.min(
control.limits?.max_frame_bytes ?? MAX_INTERACTION_MEDIA_BYTES,
MAX_INTERACTION_MEDIA_BYTES
)
} else if (control.limits?.max_frame_bytes !== undefined) {
session.maxFrameBytes = Math.min(
control.limits.max_frame_bytes,
MAX_INTERACTION_MEDIA_BYTES
)
}
sendReady(session)
session.status.textContent = 'Streaming'
} else if (control.op === 'credit') {
capture(session, control.interaction_id)
} else {
session.status.textContent =
control.op === 'error'
? control.message || 'Stream failed.'
: 'Stream ended.'
clearSession(session)
if (session.disposed) sessions.delete(session)
}
})
async function displayPendingResult(session: Session) {
if (session.decodingResult) return
session.decodingResult = true
try {
while (session.pendingResult) {
const { metadata, blob } = session.pendingResult
session.pendingResult = undefined
const interactionId = session.interactionId
try {
const bitmap = await createImageBitmap(blob)
const context = session.canvas.getContext('2d')
try {
if (
!context ||
session.disposed ||
session.interactionId !== interactionId ||
metadata.seq <= session.lastResultSeq
)
continue
session.canvas.width = bitmap.width
session.canvas.height = bitmap.height
context.drawImage(bitmap, 0, 0)
session.lastResultSeq = metadata.seq
} finally {
bitmap.close()
}
} catch {
session.status.textContent = 'Failed to display processed frame.'
}
}
} finally {
session.decodingResult = false
}
}
api.addEventListener('interaction_media', (event) => {
const { metadata, blob } = event.detail
if (metadata.channel !== 'result') return
const session = [...sessions].find(
(candidate) => candidate.interactionId === metadata.interaction_id
)
if (
!session ||
(session.promptId !== undefined &&
metadata.prompt_id !== undefined &&
session.promptId !== metadata.prompt_id) ||
blob.size > session.maxFrameBytes ||
metadata.seq <= session.lastResultSeq ||
(session.pendingResult &&
metadata.seq <= session.pendingResult.metadata.seq)
)
return
session.pendingResult = { metadata, blob }
void displayPendingResult(session)
})
api.addEventListener('reconnecting', () => {
sessions.forEach((session) => {
if (!session.interactionId) return
session.status.textContent = 'Reconnecting…'
clearTimer(session.reconnectTimer)
session.reconnectTimer = window.setTimeout(() => {
if (!session.interactionId) return
sendTerminal(session, 'cancel')
scheduleProtocolCleanup(session)
session.status.textContent = 'Stream ended after connection loss.'
}, reconnectGraceMs)
})
})
api.addEventListener('reconnected', () => {
sessions.forEach((session) => {
if (!session.interactionId) return
session.status.textContent = 'Negotiating interaction support…'
})
})
function createVideoStream(
node: LGraphNode,
definition: InteractiveDefinition
): Session {
const element = document.createElement('div')
element.className =
'flex flex-col gap-2 rounded-lg bg-node-component-surface p-2'
const surfaces = document.createElement('div')
surfaces.className = 'grid grid-cols-2 gap-2'
const source = document.createElement('div')
const sourceLabel = document.createElement('div')
sourceLabel.className = 'mb-1 text-muted-foreground'
sourceLabel.textContent =
definition.views.find((view) => view.role === 'local_source')?.label ?? ''
const video = document.createElement('video')
video.className = 'aspect-video w-full rounded bg-black object-contain'
video.muted = true
video.playsInline = true
source.append(sourceLabel, video)
const result = document.createElement('div')
const resultLabel = document.createElement('div')
resultLabel.className = 'mb-1 text-muted-foreground'
resultLabel.textContent =
definition.views.find((view) => view.role === 'remote_output')?.label ?? ''
const canvas = document.createElement('canvas')
canvas.className = 'aspect-video w-full rounded bg-black object-contain'
const captureCanvas = document.createElement('canvas')
captureCanvas.width = 640
captureCanvas.height = 480
result.append(resultLabel, canvas)
surfaces.append(source, result)
const controls = document.createElement('div')
controls.className = 'flex items-center gap-2'
const startButton = document.createElement('button')
startButton.className = 'rounded bg-primary px-3 py-1 text-primary-foreground'
startButton.textContent = 'Start Webcam'
const stopButton = document.createElement('button')
stopButton.className =
'rounded bg-secondary px-3 py-1 text-secondary-foreground'
stopButton.textContent = 'Stop Stream'
const status = document.createElement('span')
status.className = 'text-muted-foreground'
status.textContent = api.serverSupportsFeature('supports_interactions_v1')
? 'Webcam stopped.'
: 'Interactive streaming is unavailable.'
controls.append(startButton, stopButton, status)
element.append(surfaces, controls)
const session: Session = {
node,
definitionId: definition.id,
element,
video,
canvas,
captureCanvas,
status,
seq: 0,
capturing: false,
starting: false,
startGeneration: 0,
lastResultSeq: -1,
decodingResult: false,
nextCaptureAt: 0,
maxFrameBytes: MAX_INTERACTION_MEDIA_BYTES,
disposed: false
}
function updateAvailability() {
const available = api.serverSupportsFeature('supports_interactions_v1')
startButton.disabled = !available
if (session.reconnectTimer !== undefined && session.interactionId) {
clearTimer(session.reconnectTimer)
session.reconnectTimer = undefined
if (available) {
resendSessionState(session)
session.status.textContent = session.pendingTerminalOp
? 'Stream ended.'
: 'Streaming'
} else {
sendTerminal(session, 'cancel')
scheduleProtocolCleanup(session)
session.status.textContent = 'Interactive streaming is unavailable.'
}
return
}
if (!available && !session.interactionId && !session.stream)
status.textContent = 'Interactive streaming is unavailable.'
else if (
available &&
status.textContent === 'Interactive streaming is unavailable.'
)
status.textContent = 'Webcam stopped.'
}
api.addEventListener('feature_flags', updateAvailability)
updateAvailability()
startButton.addEventListener('click', () => void start(session))
stopButton.addEventListener('click', () => stopByUser(session))
session.element.addEventListener('interaction-dispose', () => {
api.removeEventListener('feature_flags', updateAvailability)
})
return session
}
const renderers = new Map([['video_stream', createVideoStream]])
export function addInteractiveViews(node: LGraphNode, nodeDef: ComfyNodeDef) {
for (const definition of nodeDef.interactive_ui ?? []) {
const renderer = renderers.get(definition.kind)
if (!renderer) continue
const session = renderer(node, definition)
sessions.add(session)
const widget = node.addDOMWidget(
`interactive:${definition.id}`,
'interactive',
session.element,
{
serialize: false,
getMinHeight: () => 220,
getValue: () => ''
}
)
widget.serialize = false
node.onRemoved = useChainCallback(node.onRemoved, () => {
session.disposed = true
session.element.dispatchEvent(new Event('interaction-dispose'))
sendTerminal(session, 'stop')
if (!session.interactionId) {
clearSession(session)
sessions.delete(session)
} else {
scheduleProtocolCleanup(session)
}
})
}
}

View File

@@ -43,6 +43,7 @@ import { createPromotedMultilineWidget } from '@/renderer/extensions/vueNodes/wi
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
import { addInteractiveViews } from '@/services/interactiveViewService'
import type { SubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type {
@@ -408,6 +409,7 @@ export const useLitegraphService = () => {
setupStrokeStyles(this)
addInputs(this, ComfyNode.nodeData.inputs)
addOutputs(this, ComfyNode.nodeData.outputs)
addInteractiveViews(this, ComfyNode.nodeData)
setInitialSize(this)
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
@@ -522,6 +524,7 @@ export const useLitegraphService = () => {
setupStrokeStyles(this)
addInputs(this, ComfyNode.nodeData.inputs)
addOutputs(this, ComfyNode.nodeData.outputs)
addInteractiveViews(this, ComfyNode.nodeData)
setInitialSize(this)
this.serialize_widgets = true

View File

@@ -12,6 +12,8 @@ import {
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useDialogService } from '@/services/dialogService'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { createTestingPinia } from '@pinia/testing'
@@ -621,6 +623,114 @@ describe('useAuthStore', () => {
})
})
describe('getAuthHeader workspace recovery', () => {
beforeEach(() => {
mockFeatureFlags.teamWorkspacesEnabled = true
})
it('uses the workspace header when a valid workspace token exists', async () => {
const workspaceAuth = useWorkspaceAuthStore()
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue({
Authorization: 'Bearer ws-token'
})
const header = await store.getAuthHeader()
expect(header).toEqual({ Authorization: 'Bearer ws-token' })
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('recovers the workspace token instead of downgrading to personal auth', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
const ensureSpy = vi
.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
.mockResolvedValue({ Authorization: 'Bearer recovered-ws-token' })
const header = await store.getAuthHeader()
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer recovered-ws-token' })
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader').mockResolvedValue(
null
)
const header = await store.getAuthHeader()
expect(header).toBeNull()
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = null
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
const header = await store.getAuthHeader()
expect(ensureSpy).not.toHaveBeenCalled()
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
})
})
describe('getAuthToken workspace recovery', () => {
beforeEach(() => {
mockFeatureFlags.teamWorkspacesEnabled = true
})
it('recovers the workspace token instead of downgrading to personal auth', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
const ensureSpy = vi
.spyOn(workspaceAuth, 'ensureWorkspaceToken')
.mockResolvedValue('recovered-ws-token')
const token = await store.getAuthToken()
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
expect(token).toBe('recovered-ws-token')
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
vi.spyOn(workspaceAuth, 'ensureWorkspaceToken').mockResolvedValue(null)
const token = await store.getAuthToken()
expect(token).toBeUndefined()
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = null
vi.spyOn(workspaceAuth, 'getWorkspaceToken').mockReturnValue(undefined)
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceToken')
const token = await store.getAuthToken()
expect(ensureSpy).not.toHaveBeenCalled()
expect(token).toBe('mock-id-token')
})
})
describe('social authentication', () => {
describe('loginWithGoogle', () => {
it('should sign in with Google', async () => {

View File

@@ -31,6 +31,7 @@ import {
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
@@ -226,7 +227,16 @@ export const useAuthStore = defineStore('auth', () => {
}
if (flags.teamWorkspacesEnabled) {
const wsHeader = useWorkspaceAuthStore().getWorkspaceAuthHeader()
const workspaceAuth = useWorkspaceAuthStore()
const activeWorkspaceId = useTeamWorkspaceStore().activeWorkspaceId
// Recover the workspace token rather than downgrade to the personal
// identity, which is what makes cloud requests oscillate.
if (activeWorkspaceId) {
return workspaceAuth.ensureWorkspaceAuthHeader(activeWorkspaceId)
}
const wsHeader = workspaceAuth.getWorkspaceAuthHeader()
if (wsHeader) return wsHeader
}
@@ -261,7 +271,18 @@ export const useAuthStore = defineStore('auth', () => {
}
if (flags.teamWorkspacesEnabled) {
const wsToken = useWorkspaceAuthStore().getWorkspaceToken()
const workspaceAuth = useWorkspaceAuthStore()
const activeWorkspaceId = useTeamWorkspaceStore().activeWorkspaceId
// Mirror getAuthHeader for WebSocket/queue auth.
if (activeWorkspaceId) {
return (
(await workspaceAuth.ensureWorkspaceToken(activeWorkspaceId)) ??
undefined
)
}
const wsToken = workspaceAuth.getWorkspaceToken()
if (wsToken) return wsToken
}

View File

@@ -96,6 +96,7 @@ export class ComfyNodeDefImpl
readonly inputs: Record<string, InputSpecV2>
readonly outputs: OutputSpecV2[]
readonly hidden?: Record<string, boolean>
readonly interactive_ui?: ComfyNodeDefV2['interactive_ui']
// ComfyNodeDefImpl fields
readonly nodeSource: NodeSource

View File

@@ -190,6 +190,29 @@ describe('TaskItemImpl', () => {
})
})
it('should recognize text files saved under the files output key', () => {
const job = createHistoryJob(0, 'job-id')
const taskItem = new TaskItemImpl(job, {
'node-1': {
files: [
{
filename: 'result.txt',
type: 'output',
subfolder: ''
}
]
}
})
const output = taskItem.flatOutputs[0]
expect(output.isText).toBe(true)
expect(output.isImage).toBe(false)
expect(output.isVideo).toBe(false)
expect(output.isAudio).toBe(false)
expect(output.supportsPreview).toBe(true)
})
it.skip('should parse text outputs', () => {
const job: JobListItem = {
...createHistoryJob(0, 'text-job'),

View File

@@ -224,7 +224,10 @@ export class ResultItemImpl {
return getMediaTypeFromFilename(this.filename) === '3D'
}
get isText(): boolean {
return this.mediaType === 'text'
return (
this.mediaType === 'text' ||
getMediaTypeFromFilename(this.filename) === 'text'
)
}
get supportsPreview(): boolean {

View File

@@ -83,6 +83,21 @@ describe(parseNodeOutput, () => {
expect(types).toContain('3d')
})
it('flattens files outputs (e.g. SaveText)', () => {
const output = makeOutput({
files: [{ filename: 'result.txt', subfolder: '', type: 'output' }],
text: 'some generated text'
})
const result = parseNodeOutput('9', output)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('result.txt')
expect(result[0].mediaType).toBe('files')
expect(result[0].isText).toBe(true)
expect(result[0].supportsPreview).toBe(true)
})
it('ignores empty arrays', () => {
const output = makeOutput({ images: [], audio: [] })
const result = parseNodeOutput('1', output)