mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
Compare commits
8 Commits
move-image
...
feat/inter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3a13b5b4e | ||
|
|
d28875c4d8 | ||
|
|
df9b5bfa0a | ||
|
|
a6b7ce11aa | ||
|
|
d3b100be8d | ||
|
|
54b0c10148 | ||
|
|
2b540a5281 | ||
|
|
51156c5503 |
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
31
apps/website/src/composables/useCurrentPath.test.ts
Normal file
31
apps/website/src/composables/useCurrentPath.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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' }),
|
||||
|
||||
23
apps/website/src/config/routes.test.ts
Normal file
23
apps/website/src/config/routes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
120
docs/adr/0011-derived-credential-lifecycle.md
Normal file
120
docs/adr/0011-derived-credential-lifecycle.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
9
docs/testing/litegraph-testing.md
Normal file
9
docs/testing/litegraph-testing.md
Normal 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.
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
public/assets/images/gemini.svg
Normal file
3
public/assets/images/gemini.svg
Normal 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 |
4
public/assets/images/runway.svg
Normal file
4
public/assets/images/runway.svg
Normal 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 |
@@ -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<{
|
||||
|
||||
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal 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>
|
||||
71
src/composables/useTextFileContent.test.ts
Normal file
71
src/composables/useTextFileContent.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
40
src/composables/useTextFileContent.ts
Normal file
40
src/composables/useTextFileContent.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
24
src/extensions/core/saveText.ts
Normal file
24
src/extensions/core/saveText.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
103
src/extensions/core/textPreviewWidgets.test.ts
Normal file
103
src/extensions/core/textPreviewWidgets.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
78
src/extensions/core/textPreviewWidgets.ts
Normal file
78
src/extensions/core/textPreviewWidgets.ts
Normal 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
|
||||
}
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
52
src/platform/assets/components/MediaTextTop.test.ts
Normal file
52
src/platform/assets/components/MediaTextTop.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('../composables/useSecretForm', () => ({
|
||||
loading: false,
|
||||
apiError: '',
|
||||
providerOptions: [],
|
||||
providerHelp: '',
|
||||
handleSubmit: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('../composables/useSecretForm', () => ({
|
||||
loading: false,
|
||||
apiError: '',
|
||||
providerOptions: [],
|
||||
providerHelp: '',
|
||||
handleSubmit: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
51
src/platform/secrets/providers.test.ts
Normal file
51
src/platform/secrets/providers.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
88
src/services/interactionProtocol.test.ts
Normal file
88
src/services/interactionProtocol.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
153
src/services/interactionProtocol.ts
Normal file
153
src/services/interactionProtocol.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
262
src/services/interactiveViewService.test.ts
Normal file
262
src/services/interactiveViewService.test.ts
Normal 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?.()
|
||||
})
|
||||
})
|
||||
573
src/services/interactiveViewService.ts
Normal file
573
src/services/interactiveViewService.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user