mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
Compare commits
66 Commits
v1.47.9
...
synap5e/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b017b26499 | ||
|
|
84491c3c60 | ||
|
|
e8f5617da7 | ||
|
|
65f80ecee7 | ||
|
|
f6bbaf4b9e | ||
|
|
1e7d48623a | ||
|
|
b14353d340 | ||
|
|
c8afd4cf8a | ||
|
|
a8c5476d3f | ||
|
|
abfb89b990 | ||
|
|
259fd9a62f | ||
|
|
724e31d235 | ||
|
|
9a7793ccc8 | ||
|
|
317cc81196 | ||
|
|
3a73d41d73 | ||
|
|
64740d9d4a | ||
|
|
9788ed2439 | ||
|
|
7b841548ce | ||
|
|
fcb7d838ef | ||
|
|
873a85e59e | ||
|
|
ca33752569 | ||
|
|
033ef6069c | ||
|
|
1b44590a87 | ||
|
|
561b519944 | ||
|
|
954f935d33 | ||
|
|
e6916bb665 | ||
|
|
fdc4651934 | ||
|
|
62123c4c0d | ||
|
|
1cb04bef92 | ||
|
|
f5e221b955 | ||
|
|
fd1f2726a5 | ||
|
|
8d51d933fc | ||
|
|
be564b232b | ||
|
|
2ef341dcd8 | ||
|
|
1815c7f7a4 | ||
|
|
287b9eb980 | ||
|
|
06b0471257 | ||
|
|
8120142f49 | ||
|
|
3164e6ab61 | ||
|
|
731512c655 | ||
|
|
c0ad1e98c2 | ||
|
|
bd9fab2d2f | ||
|
|
b62405a6ca | ||
|
|
33c1806673 | ||
|
|
9342caad0a | ||
|
|
48aed9e0d9 | ||
|
|
dd3dceeaca | ||
|
|
3605fc1d75 | ||
|
|
f97195e392 | ||
|
|
0488d0d8a4 | ||
|
|
aea28a06de | ||
|
|
5847b3d148 | ||
|
|
187239712c | ||
|
|
e53b7b4ba8 | ||
|
|
ab56cf9e82 | ||
|
|
f772fc0123 | ||
|
|
a5f4559df8 | ||
|
|
6103dd164e | ||
|
|
f7d672e3a5 | ||
|
|
6f0eaefe1b | ||
|
|
55ceec0a16 | ||
|
|
5ac1b15266 | ||
|
|
df826415ca | ||
|
|
ec25e874a2 | ||
|
|
c8e4029f96 | ||
|
|
5ebeb580ca |
3
.github/workflows/ci-website-build.yaml
vendored
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,3 +40,6 @@ jobs:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
@@ -76,10 +76,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
|
||||
158
apps/website/e2e/learning.spec.ts
Normal file
158
apps/website/e2e/learning.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning — Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 — Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,8 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
129
apps/website/scripts/validate-jsonld.ts
Normal file
129
apps/website/scripts/validate-jsonld.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
41
apps/website/src/components/blocks/HeroBackdrop01.stories.ts
Normal file
41
apps/website/src/components/blocks/HeroBackdrop01.stories.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
193
apps/website/src/components/blocks/HeroBackdrop01.vue
Normal file
193
apps/website/src/components/blocks/HeroBackdrop01.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
53
apps/website/src/config/pricing.ts
Normal file
53
apps/website/src/config/pricing.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { t } from '../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
import { externalLinks } from './routes'
|
||||
|
||||
interface PricingTier {
|
||||
slug: string
|
||||
labelKey: TranslationKey
|
||||
priceKey: TranslationKey
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
slug: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price'
|
||||
},
|
||||
{
|
||||
slug: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price'
|
||||
},
|
||||
{
|
||||
slug: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price'
|
||||
}
|
||||
]
|
||||
|
||||
export interface PricingOffer {
|
||||
name: string
|
||||
price: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function pricingOffers(locale: Locale): PricingOffer[] {
|
||||
return tiers.flatMap((tier) => {
|
||||
const display = t(tier.priceKey, locale).trim()
|
||||
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
|
||||
)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: t(tier.labelKey, locale),
|
||||
price: match[1],
|
||||
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -82,14 +82,19 @@ export const externalLinks = {
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
g2ComfyUi: 'https://www.g2.com/products/comfyui',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
|
||||
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
|
||||
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -2190,6 +2190,13 @@ const translations = {
|
||||
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
|
||||
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
|
||||
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
|
||||
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
|
||||
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
|
||||
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
|
||||
@@ -4061,7 +4068,6 @@ const translations = {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
@@ -4157,10 +4163,6 @@ const translations = {
|
||||
en: "Run the world's leading AI models in ComfyUI",
|
||||
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.breadcrumb.home': {
|
||||
en: 'Home',
|
||||
'zh-CN': '首页'
|
||||
},
|
||||
'models.breadcrumb.models': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
import { buildPageGraph, pageContext } from '../utils/jsonLd'
|
||||
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
|
||||
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -23,6 +25,10 @@ interface Props {
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
pageType?: WebPageType
|
||||
breadcrumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
extraJsonLd?: (JsonLdNode | null | undefined)[]
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -31,15 +37,21 @@ const {
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
pageType,
|
||||
breadcrumbs,
|
||||
mainEntityId,
|
||||
extraJsonLd,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const canonicalURL = new URL(url)
|
||||
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
@@ -58,28 +70,21 @@ const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/icons/logomark.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
const structuredData = noindex
|
||||
? undefined
|
||||
: buildPageGraph(
|
||||
{ siteUrl, locale },
|
||||
{
|
||||
url,
|
||||
name: title,
|
||||
description,
|
||||
imageUrl: ogImageURL.href,
|
||||
type: pageType,
|
||||
crumbs: breadcrumbs,
|
||||
mainEntityId,
|
||||
},
|
||||
...(extraJsonLd ?? []),
|
||||
)
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -121,10 +126,7 @@ const websiteJsonLd = {
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
{structuredData && <JsonLdGraph graph={structuredData} />}
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -144,7 +146,6 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
|
||||
@@ -5,9 +5,25 @@ import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<BaseLayout
|
||||
title="About Us — Comfy"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
|
||||
@@ -9,34 +9,36 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
const pageTitle = t('affiliate.page.title', 'en')
|
||||
const pageDescription = t('affiliate.page.description', 'en')
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
}))
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: pageTitle },
|
||||
]}
|
||||
extraJsonLd={[faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
|
||||
@@ -7,6 +7,13 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -19,11 +26,31 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Careers — Comfy"
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
|
||||
@@ -2,9 +2,41 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../config/pricing'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout title="Pricing — Comfy Cloud">
|
||||
<BaseLayout
|
||||
title="Pricing — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<PriceSection client:load />
|
||||
<WhatsIncludedSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,39 +4,44 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
const title = t('cloudNodes.meta.title', 'en')
|
||||
const description = t('cloudNodes.meta.description', 'en')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,7 +7,12 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -29,35 +34,45 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
description: pack.description || undefined,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import FormSection from '../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="Contact — Comfy">
|
||||
<BaseLayout
|
||||
title="Contact — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<FormSection client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,6 +7,13 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
}
|
||||
---
|
||||
|
||||
@@ -88,25 +61,20 @@ const breadcrumbJsonLd = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,11 +8,29 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../components/product/local/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
@@ -9,11 +9,28 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
|
||||
import GetStartedSection from "../components/home/GetStartedSection.vue";
|
||||
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
|
||||
import { t } from "../i18n/translations";
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from "../utils/jsonLd";
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t("hero.subtitle", "en")}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[
|
||||
comfyUiApplicationNode(siteUrl),
|
||||
comfyUiSourceCodeNode(siteUrl),
|
||||
]}
|
||||
keywords={[
|
||||
"comfyui app",
|
||||
"comfyui web app",
|
||||
|
||||
@@ -4,6 +4,13 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
@@ -19,7 +26,6 @@ if (model.canonicalSlug) {
|
||||
}
|
||||
|
||||
const { displayName } = model
|
||||
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
|
||||
|
||||
const dirDescriptions: Record<string, string> = {
|
||||
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
|
||||
@@ -40,55 +46,31 @@ const dirDescriptions: Record<string, string> = {
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: displayName,
|
||||
url,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Any',
|
||||
url: canonicalURL.href,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('models.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('models.breadcrumb.models'),
|
||||
item: 'https://comfy.org/p/supported-models'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
})
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `What is ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: whatIsDescription
|
||||
}
|
||||
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
@@ -97,54 +79,44 @@ const faqJsonLd = {
|
||||
'@type': 'Answer',
|
||||
text: model.docsUrl
|
||||
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
|
||||
}
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
|
||||
}
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `Is ${displayName} free to use in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
|
||||
}
|
||||
}
|
||||
]
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('models.breadcrumb.models', locale),
|
||||
url: absoluteUrl(Astro.site, '/p/supported-models'),
|
||||
},
|
||||
{ name: displayName },
|
||||
]}
|
||||
extraJsonLd={[software, faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(softwareAppJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<ModelHeroSection
|
||||
displayName={displayName}
|
||||
|
||||
@@ -2,10 +2,29 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { models } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const title = t('models.index.title')
|
||||
const subtitle = t('models.index.subtitle')
|
||||
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const modelList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
models.map((model) => ({
|
||||
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
|
||||
})),
|
||||
)
|
||||
|
||||
const dirLabel: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion',
|
||||
checkpoints: 'Checkpoint',
|
||||
@@ -26,6 +45,13 @@ const dirLabel: Record<string, string> = {
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={subtitle}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[modelList]}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<header class="mb-12">
|
||||
|
||||
@@ -5,9 +5,29 @@ import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<BaseLayout
|
||||
title="关于我们 — Comfy"
|
||||
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
|
||||
@@ -7,6 +7,13 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../../utils/ashby.ci'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -19,11 +26,34 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
|
||||
@@ -2,9 +2,44 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../../config/pricing'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout title="定价 — Comfy Cloud">
|
||||
<BaseLayout
|
||||
title="定价 — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<PriceSection locale="zh-CN" client:load />
|
||||
<WhatsIncludedSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,39 +4,47 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
const title = t('cloudNodes.meta.title', 'zh-CN')
|
||||
const description = t('cloudNodes.meta.description', 'zh-CN')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,7 +7,12 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -29,35 +34,48 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
description: pack.description || undefined,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,9 +2,28 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import FormSection from '../../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="联系我们 — Comfy">
|
||||
<BaseLayout
|
||||
title="联系我们 — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<FormSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,6 +7,13 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
}
|
||||
---
|
||||
|
||||
@@ -88,25 +61,23 @@ const breadcrumbJsonLd = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,11 +8,32 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
|
||||
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/local/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
@@ -9,11 +9,25 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
|
||||
import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
|
||||
212
apps/website/src/utils/jsonLd.test.ts
Normal file
212
apps/website/src/utils/jsonLd.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { externalLinks } from '../config/routes'
|
||||
import { escapeJsonLd } from './escapeJsonLd'
|
||||
import type { JsonLdGraph } from './jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildPageGraph,
|
||||
collectGraphIds,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
productNode,
|
||||
softwareApplicationNode
|
||||
} from './jsonLd'
|
||||
|
||||
const siteUrl = 'https://comfy.org'
|
||||
const site = new URL('https://comfy.org/')
|
||||
|
||||
function typeNames(graph: JsonLdGraph): string[] {
|
||||
return graph['@graph'].map((node) => node['@type'])
|
||||
}
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('resolves internal paths to their trailing-slash canonical form', () => {
|
||||
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
|
||||
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
|
||||
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageContext', () => {
|
||||
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
|
||||
expect(pageContext(site, '/about/', undefined)).toEqual({
|
||||
siteUrl,
|
||||
locale: 'en',
|
||||
url: 'https://comfy.org/about/'
|
||||
})
|
||||
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemListNode', () => {
|
||||
it('counts items and omits per-item names when not supplied', () => {
|
||||
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
|
||||
{ url: 'https://jobs.example/1' },
|
||||
{ url: 'https://jobs.example/2', name: 'Designer' }
|
||||
])
|
||||
expect(node.numberOfItems).toBe(2)
|
||||
const items = node.itemListElement as Record<string, unknown>[]
|
||||
expect('name' in items[0]).toBe(false)
|
||||
expect(items[1].name).toBe('Designer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('softwareApplicationNode', () => {
|
||||
it('claims Comfy Org as author and publisher only when first-party', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: jsonLdId(siteUrl, 'software'),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
isFree: true
|
||||
})
|
||||
const orgRef = { '@id': organizationId(siteUrl) }
|
||||
expect(node.author).toEqual(orgRef)
|
||||
expect(node.publisher).toEqual(orgRef)
|
||||
expect(node.offers).toEqual({
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: orgRef
|
||||
})
|
||||
})
|
||||
|
||||
it('does not name Comfy Org as seller on a third-party free offer', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
isFree: true
|
||||
})
|
||||
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
|
||||
})
|
||||
|
||||
it('credits a known third-party author without claiming to publish it', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
authorName: 'Jane Dev'
|
||||
})
|
||||
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
|
||||
it('claims no author or publisher for third-party software with no author', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.author).toBeUndefined()
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameAs encyclopedic references', () => {
|
||||
it('links the organization to its Wikidata entity', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/`, name: 'Home' }
|
||||
)
|
||||
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
|
||||
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
|
||||
})
|
||||
|
||||
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.sameAs).toEqual([
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
])
|
||||
})
|
||||
|
||||
it('omits sameAs for third-party software', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.sameAs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('productNode', () => {
|
||||
it('gives every offer a currency and price', () => {
|
||||
const node = productNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/pricing/#product',
|
||||
name: 'Comfy Cloud',
|
||||
url: 'https://comfy.org/cloud/pricing/',
|
||||
offers: [{ name: 'Standard', price: '20' }]
|
||||
})
|
||||
const offers = node.offers as Record<string, unknown>[]
|
||||
expect(offers[0].price).toBe('20')
|
||||
expect(offers[0].priceCurrency).toBe('USD')
|
||||
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('comfyUiSourceCodeNode', () => {
|
||||
it('links the source code to the ComfyUI application via targetProduct', () => {
|
||||
const node = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{
|
||||
url,
|
||||
name: 'Pricing',
|
||||
type: 'CollectionPage',
|
||||
mainEntityId: jsonLdId(url, 'itemlist'),
|
||||
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
|
||||
},
|
||||
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
|
||||
)
|
||||
|
||||
it('always includes the site-wide organization, website and page entity', () => {
|
||||
expect(typeNames(graph)).toContain('Organization')
|
||||
expect(typeNames(graph)).toContain('WebSite')
|
||||
expect(typeNames(graph)).toContain('CollectionPage')
|
||||
})
|
||||
|
||||
it('produces a graph where every @id reference resolves', () => {
|
||||
const { defined, references } = collectGraphIds(graph)
|
||||
for (const reference of references) {
|
||||
expect(defined.has(reference)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeJsonLd on a built graph', () => {
|
||||
it('neutralizes a </script> breakout in a page name', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
|
||||
)
|
||||
const serialized = escapeJsonLd(graph)
|
||||
expect(serialized).not.toContain('</script>')
|
||||
expect(serialized).toContain('\\u003c')
|
||||
})
|
||||
})
|
||||
377
apps/website/src/utils/jsonLd.ts
Normal file
377
apps/website/src/utils/jsonLd.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
|
||||
export type JsonLdNode = Record<string, unknown> & { '@type': string }
|
||||
|
||||
export interface JsonLdGraph {
|
||||
'@context': 'https://schema.org'
|
||||
'@graph': JsonLdNode[]
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
export type WebPageType =
|
||||
| 'WebPage'
|
||||
| 'AboutPage'
|
||||
| 'ContactPage'
|
||||
| 'CollectionPage'
|
||||
|
||||
export interface Crumb {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const sameAs = [
|
||||
externalLinks.github,
|
||||
externalLinks.x,
|
||||
externalLinks.youtube,
|
||||
externalLinks.discord,
|
||||
externalLinks.instagram,
|
||||
externalLinks.reddit,
|
||||
externalLinks.linkedin,
|
||||
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
|
||||
externalLinks.wikidataComfyOrg
|
||||
]
|
||||
|
||||
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
|
||||
const comfyUiSameAs = [
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
]
|
||||
|
||||
function siteUrlFrom(site: URL | undefined): string {
|
||||
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function absoluteUrl(site: URL | undefined, path: string): string {
|
||||
const resolved = new URL(path, site ?? 'https://comfy.org').href
|
||||
return resolved.endsWith('/') ? resolved : `${resolved}/`
|
||||
}
|
||||
|
||||
export function pageContext(
|
||||
site: URL | undefined,
|
||||
pathname: string,
|
||||
currentLocale: string | undefined
|
||||
): PageContext & { url: string } {
|
||||
return {
|
||||
siteUrl: siteUrlFrom(site),
|
||||
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
|
||||
url: absoluteUrl(site, pathname)
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonLdId(pageUrl: string, fragment: string): string {
|
||||
return `${pageUrl}#${fragment}`
|
||||
}
|
||||
|
||||
export function organizationId(siteUrl: string): string {
|
||||
return `${siteUrl}/#organization`
|
||||
}
|
||||
|
||||
function websiteId(siteUrl: string): string {
|
||||
return `${siteUrl}/#website`
|
||||
}
|
||||
|
||||
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
|
||||
}
|
||||
}
|
||||
|
||||
function organizationNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Organization',
|
||||
'@id': organizationId(siteUrl),
|
||||
name: 'Comfy Org',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/web-app-manifest-512x512.png`,
|
||||
width: 512,
|
||||
height: 512
|
||||
},
|
||||
sameAs
|
||||
}
|
||||
}
|
||||
|
||||
function websiteNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId(siteUrl),
|
||||
name: 'Comfy',
|
||||
url: siteUrl,
|
||||
publisher: { '@id': organizationId(siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': jsonLdId(pageUrl, 'breadcrumb'),
|
||||
itemListElement: crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return isLast || !crumb.url
|
||||
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
|
||||
: {
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: crumb.url
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function itemListNode(
|
||||
pageUrl: string,
|
||||
name: string,
|
||||
items: { url: string; name?: string }[]
|
||||
): JsonLdNode {
|
||||
return {
|
||||
'@type': 'ItemList',
|
||||
'@id': jsonLdId(pageUrl, 'itemlist'),
|
||||
name,
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: item.url,
|
||||
...(item.name ? { name: item.name } : {})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
interface WebPageInput {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
url: string
|
||||
name: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
|
||||
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
|
||||
return {
|
||||
'@type': type,
|
||||
'@id': jsonLdId(input.url, 'webpage'),
|
||||
url: input.url,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isPartOf: { '@id': websiteId(input.siteUrl) },
|
||||
primaryImageOfPage: input.imageUrl
|
||||
? { '@type': 'ImageObject', url: input.imageUrl }
|
||||
: undefined,
|
||||
breadcrumb: hasCrumbs
|
||||
? { '@id': jsonLdId(input.url, 'breadcrumb') }
|
||||
: undefined,
|
||||
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
|
||||
inLanguage: input.locale
|
||||
}
|
||||
}
|
||||
|
||||
export interface SoftwareAppInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
applicationCategory: string
|
||||
firstParty?: boolean
|
||||
applicationSubCategory?: string
|
||||
description?: string
|
||||
operatingSystem?: string
|
||||
image?: string
|
||||
softwareVersion?: string
|
||||
license?: string
|
||||
codeRepository?: string
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
const orgRef = { '@id': organizationId(input.siteUrl) }
|
||||
const author = input.firstParty
|
||||
? orgRef
|
||||
: input.authorName
|
||||
? { '@type': 'Person', name: input.authorName }
|
||||
: undefined
|
||||
return {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
applicationCategory: input.applicationCategory,
|
||||
applicationSubCategory: input.applicationSubCategory,
|
||||
description: input.description,
|
||||
operatingSystem: input.operatingSystem,
|
||||
image: input.image,
|
||||
softwareVersion: input.softwareVersion,
|
||||
license: input.license,
|
||||
codeRepository: input.codeRepository,
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: input.firstParty ? orgRef : undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface SourceCodeInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
codeRepository: string
|
||||
programmingLanguage?: string
|
||||
targetProductId?: string
|
||||
}
|
||||
|
||||
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'SoftwareSourceCode',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
codeRepository: input.codeRepository,
|
||||
programmingLanguage: input.programmingLanguage,
|
||||
targetProduct: input.targetProductId
|
||||
? { '@id': input.targetProductId }
|
||||
: undefined,
|
||||
author: { '@id': organizationId(input.siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: comfyUiSoftwareId(siteUrl),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
targetProductId: comfyUiSoftwareId(siteUrl)
|
||||
})
|
||||
}
|
||||
|
||||
interface OfferInput {
|
||||
name: string
|
||||
price: string | number
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface ProductInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
offers: OfferInput[]
|
||||
}
|
||||
|
||||
export function productNode(input: ProductInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Product',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
brand: { '@id': organizationId(input.siteUrl) },
|
||||
offers: input.offers.map((offer) => ({
|
||||
'@type': 'Offer',
|
||||
name: offer.name,
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
url: offer.url,
|
||||
seller: { '@id': organizationId(input.siteUrl) },
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export interface PageGraphInput {
|
||||
url: string
|
||||
name: string
|
||||
type?: WebPageType
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
export function buildPageGraph(
|
||||
ctx: PageContext,
|
||||
page: PageGraphInput,
|
||||
...extraNodes: (JsonLdNode | null | undefined)[]
|
||||
): JsonLdGraph {
|
||||
const { type = 'WebPage', ...rest } = page
|
||||
const input: WebPageInput = {
|
||||
...rest,
|
||||
siteUrl: ctx.siteUrl,
|
||||
locale: ctx.locale
|
||||
}
|
||||
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
|
||||
return buildGraph(
|
||||
organizationNode(ctx.siteUrl),
|
||||
websiteNode(ctx.siteUrl),
|
||||
webPageNode(input, type),
|
||||
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
|
||||
...extraNodes
|
||||
)
|
||||
}
|
||||
|
||||
export function collectGraphIds(value: unknown): {
|
||||
defined: Set<string>
|
||||
references: string[]
|
||||
} {
|
||||
const defined = new Set<string>()
|
||||
const references: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>
|
||||
const id = record['@id']
|
||||
if (typeof id === 'string') {
|
||||
if (Object.keys(record).length === 1) references.push(id)
|
||||
else defined.add(id)
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return { defined, references }
|
||||
}
|
||||
@@ -254,6 +254,18 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder's own row (not the whole subtree). Required for nested folders:
|
||||
* an ancestor `.p-tree-node`'s text contains its descendants' labels, so
|
||||
* `getFolderByLabel` would match — and click — the ancestor instead.
|
||||
*/
|
||||
getFolderRowByLabel(label: string) {
|
||||
return this.modelTree
|
||||
.locator('.p-tree-node:not(.p-tree-node-leaf) > .p-tree-node-content')
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Core-native asset shape: the ingest Asset plus the `loader_path` contract
|
||||
* field that `supports_model_type_tags` backends emit (see
|
||||
* `src/platform/assets/schemas/assetSchema.ts`).
|
||||
*/
|
||||
export type CoreModelAsset = Asset & Pick<AssetItem, 'loader_path'>
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
@@ -89,6 +98,70 @@ export const STABLE_LORA: Asset = createModelAsset({
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
function createCoreModelAsset(
|
||||
overrides: Partial<CoreModelAsset>
|
||||
): CoreModelAsset {
|
||||
return { ...createModelAsset(), ...overrides }
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_NESTED: CoreModelAsset =
|
||||
createCoreModelAsset({
|
||||
id: 'mt-checkpoint-001',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'SDXL/sd_xl_base_1.0.safetensors',
|
||||
created_at: '2025-01-15T10:30:00Z',
|
||||
updated_at: '2025-01-15T10:30:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_ROOT: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-checkpoint-002',
|
||||
name: 'v1-5-pruned-emaonly.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'v1-5-pruned-emaonly.safetensors',
|
||||
created_at: '2025-01-20T08:00:00Z',
|
||||
updated_at: '2025-01-20T08:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_GGUF: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-checkpoint-003',
|
||||
name: 'flux_quantized.gguf',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'flux_quantized.gguf',
|
||||
created_at: '2025-02-01T09:00:00Z',
|
||||
updated_at: '2025-02-01T09:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_SCANNED: CoreModelAsset =
|
||||
createCoreModelAsset({
|
||||
id: 'mt-checkpoint-004',
|
||||
name: 'freshly_scanned.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'freshly_scanned.safetensors',
|
||||
created_at: '2025-02-10T09:00:00Z',
|
||||
updated_at: '2025-02-10T09:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_LORA: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-lora-001',
|
||||
name: 'detail_enhancer_v1.2.safetensors',
|
||||
tags: ['models', 'model_type:loras'],
|
||||
loader_path: 'detail_enhancer_v1.2.safetensors',
|
||||
created_at: '2025-02-20T14:00:00Z',
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_LORA_README: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-lora-002',
|
||||
name: 'README.txt',
|
||||
mime_type: 'text/plain',
|
||||
size: 2_048,
|
||||
tags: ['models', 'model_type:loras'],
|
||||
loader_path: 'README.txt',
|
||||
created_at: '2025-02-20T14:00:00Z',
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
id: 'test-input-001',
|
||||
name: 'reference_photo.png',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ListAssetsResponse,
|
||||
SeedAssetsResponse,
|
||||
UpdateAssetData
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
@@ -35,6 +36,13 @@ function emptyConfig(): AssetConfig {
|
||||
|
||||
type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
|
||||
/**
|
||||
* Scoped to the API path so the built frontend's own `/assets/*.js` chunks
|
||||
* are never intercepted (a page navigated after `mock()` would otherwise
|
||||
* fail to load).
|
||||
*/
|
||||
const ASSET_API_ROUTE_PATTERN = '**/api/assets**'
|
||||
|
||||
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
|
||||
const merged = new Map(config.assets)
|
||||
for (const asset of newAssets) {
|
||||
@@ -141,6 +149,8 @@ export class AssetHelper {
|
||||
return this.handleUpdateAsset(route, path, body)
|
||||
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleDeleteAsset(route, path)
|
||||
if (method === 'POST' && path.endsWith('/assets/seed'))
|
||||
return this.handleSeedScan(route)
|
||||
if (method === 'POST' && /\/assets\/?$/.test(path))
|
||||
return this.handleUploadAsset(route)
|
||||
if (method === 'POST' && path.endsWith('/assets/download'))
|
||||
@@ -149,7 +159,7 @@ export class AssetHelper {
|
||||
return route.fallback()
|
||||
}
|
||||
|
||||
const pattern = '**/assets**'
|
||||
const pattern = ASSET_API_ROUTE_PATTERN
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
@@ -165,7 +175,7 @@ export class AssetHelper {
|
||||
})
|
||||
}
|
||||
|
||||
const pattern = '**/assets**'
|
||||
const pattern = ASSET_API_ROUTE_PATTERN
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
@@ -276,6 +286,11 @@ export class AssetHelper {
|
||||
return route.fulfill({ status: 201, json: response })
|
||||
}
|
||||
|
||||
private handleSeedScan(route: Route) {
|
||||
const response: SeedAssetsResponse = { status: 'started' }
|
||||
return route.fulfill({ status: 200, json: response })
|
||||
}
|
||||
|
||||
private handleDownloadAsset(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 202,
|
||||
|
||||
@@ -51,6 +51,22 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Force server feature flags (the WS `feature_flags` handshake payload) on
|
||||
* the running app by merging into `api.serverFeatureFlags`. The `ff:`
|
||||
* localStorage override is dev-only (tree-shaken from production builds),
|
||||
* so this is the way to control `api.serverSupportsFeature()` in e2e.
|
||||
* Call after `comfyPage.setup()` so the real handshake cannot clobber it.
|
||||
*/
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
20
browser_tests/fixtures/utils/dispatchApiEvent.ts
Normal file
20
browser_tests/fixtures/utils/dispatchApiEvent.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Dispatches a wire-level custom event on the app's api singleton, simulating
|
||||
* a websocket broadcast (e.g. `assets.seed.fast_complete`). Uses the raw
|
||||
* EventTarget dispatch because `api.dispatchCustomEvent` is typed against the
|
||||
* ApiEventTypes map, which deliberately excludes events consumed via
|
||||
* `addCustomEventListener`.
|
||||
*/
|
||||
export async function dispatchApiCustomEvent(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<void> {
|
||||
await page.evaluate((eventType) => {
|
||||
EventTarget.prototype.dispatchEvent.call(
|
||||
window.app!.api,
|
||||
new CustomEvent(eventType)
|
||||
)
|
||||
}, type)
|
||||
}
|
||||
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
* add a provider key, see it listed, delete it — the full CRUD round-trip —
|
||||
* plus the entitlement contract that a non-entitled account never sees the
|
||||
* gated providers.
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// --- ADD -------------------------------------------------------------
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
// Pick the entitled Runway provider from the server-driven dropdown.
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
await page.getByRole('option', { name: 'Runway' }).click()
|
||||
|
||||
await formDialog.locator('#secret-name').fill('My Runway Key')
|
||||
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
|
||||
|
||||
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(formDialog).toBeHidden()
|
||||
|
||||
// --- LIST ------------------------------------------------------------
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
|
||||
|
||||
// The create request carried the plaintext value + provider...
|
||||
expect(backend.createRequests).toHaveLength(1)
|
||||
expect(backend.createRequests[0]).toMatchObject({
|
||||
name: 'My Runway Key',
|
||||
provider: 'runway',
|
||||
secret_value: RUNWAY_KEY_VALUE
|
||||
})
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
const confirmDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Delete Secret' })
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
expect(backend.store).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
await mockSecretsBackend(page, [])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
// providers must not appear anywhere.
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
// Anchor on the opened listbox so the absence assertions below can't pass
|
||||
// vacuously against a dropdown that never opened.
|
||||
const providerListbox = page.getByRole('listbox')
|
||||
await expect(providerListbox).toBeVisible()
|
||||
// An empty allowlist must yield an empty dropdown. Asserting zero options
|
||||
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
|
||||
// where `availableProviders` is null and the default providers would show.
|
||||
await expect(providerListbox.getByRole('option')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,13 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
|
||||
wstest(
|
||||
'restoring workflow restores state',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
|
||||
const preview = node.root.locator('textarea')
|
||||
|
||||
await test.step('node previews execution result', async () => {
|
||||
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
|
||||
execution.executed('', id, { text: 'massive fennec ears' })
|
||||
await expect(preview).toHaveValue('massive fennec ears')
|
||||
})
|
||||
|
||||
await test.step('swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(node.root).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await expect(node.root).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(preview, 'previous output is restored').toHaveValue(
|
||||
'massive fennec ears'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
231
browser_tests/tests/sidebar/modelLibraryAssetMode.spec.ts
Normal file
231
browser_tests/tests/sidebar/modelLibraryAssetMode.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
MODEL_TYPE_CHECKPOINT_GGUF,
|
||||
MODEL_TYPE_CHECKPOINT_NESTED,
|
||||
MODEL_TYPE_CHECKPOINT_ROOT,
|
||||
MODEL_TYPE_CHECKPOINT_SCANNED,
|
||||
MODEL_TYPE_LORA,
|
||||
MODEL_TYPE_LORA_README,
|
||||
STABLE_CHECKPOINT
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { withModels } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { dispatchApiCustomEvent } from '@e2e/fixtures/utils/dispatchApiEvent'
|
||||
import type { ModelFolderInfo } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
// Deliberately not alphabetical: the sidebar must show folders in backend
|
||||
// registration order, so 'loras' listed first must render first.
|
||||
const REGISTERED_FOLDERS: ModelFolderInfo[] = [
|
||||
{ name: 'loras', folders: ['/models/loras'], extensions: [] },
|
||||
{
|
||||
name: 'checkpoints',
|
||||
folders: ['/models/checkpoints'],
|
||||
extensions: ['.safetensors', '.gguf']
|
||||
}
|
||||
]
|
||||
|
||||
const WALK_ASSETS: Asset[] = [
|
||||
MODEL_TYPE_CHECKPOINT_NESTED,
|
||||
MODEL_TYPE_CHECKPOINT_ROOT,
|
||||
MODEL_TYPE_CHECKPOINT_GGUF,
|
||||
MODEL_TYPE_LORA,
|
||||
MODEL_TYPE_LORA_README
|
||||
]
|
||||
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.Assets.UseAssetAPI': true,
|
||||
'Comfy.ModelLibrary.UseAssetBrowser': false
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - asset mode', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(withModels(WALK_ASSETS))
|
||||
await assetApi.mock()
|
||||
await comfyPage.modelLibrary.mockModelFolders(REGISTERED_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.featureFlags.setServerFlags({
|
||||
supports_model_type_tags: true
|
||||
})
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Lists folders in backend registration order', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await expect(tab.folderNodes.nth(0)).toContainText('loras')
|
||||
await expect(tab.folderNodes.nth(1)).toContainText('checkpoints')
|
||||
})
|
||||
|
||||
test('Eager-loads models and drops the load-all button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await expect(tab.refreshButton).toBeVisible()
|
||||
await expect(tab.loadAllFoldersButton).toBeHidden()
|
||||
|
||||
// Models render from the eager walk on expansion, with loader_path
|
||||
// subdirectories as nested folders.
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
|
||||
await tab.getFolderRowByLabel('SDXL').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Applies registered extension allowlists verbatim and default-filters match-all folders', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
// checkpoints registers ['.safetensors', '.gguf'], so the .gguf model
|
||||
// shows even though the legacy fixed list would have hidden it.
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('flux_quantized.gguf')).toBeVisible()
|
||||
|
||||
// loras is registered match-all (empty allowlist); the FE substitutes
|
||||
// the default model-extension list, hiding non-model noise.
|
||||
await tab.getFolderRowByLabel('loras').click()
|
||||
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('README')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Refresh seeds a backend rescan', async ({ comfyPage, assetApi }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.refreshButton.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
assetApi
|
||||
.getMutations()
|
||||
.find(
|
||||
(mutation) =>
|
||||
mutation.method === 'POST' &&
|
||||
mutation.endpoint.endsWith('/assets/seed')
|
||||
)?.body
|
||||
)
|
||||
.toEqual({ roots: ['models'] })
|
||||
})
|
||||
|
||||
test('Live-updates the tree when the scan fast-phase completes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('freshly_scanned')).toBeHidden()
|
||||
|
||||
assetApi.configure(
|
||||
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
|
||||
)
|
||||
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
|
||||
|
||||
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Active search results update when the scan fast-phase completes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
// Search an existing model first: its result proves the eager load and
|
||||
// the debounced search pipeline have settled, so the later update can
|
||||
// only come from the scan event, not from a still-pending load.
|
||||
await tab.searchInput.fill('detail_enhancer')
|
||||
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('freshly')
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
|
||||
assetApi.configure(
|
||||
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
|
||||
)
|
||||
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
|
||||
|
||||
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Placing a model fills the loader with the category-relative loader path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await tab.getFolderRowByLabel('SDXL').click()
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
// The visible ghost preview marks arming as complete, so a zero node
|
||||
// count here proves nothing is placed until the canvas is clicked.
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toBeVisible()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('SDXL/sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - asset mode on bare-tag backends', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(withModels([STABLE_CHECKPOINT]))
|
||||
await assetApi.mock()
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{
|
||||
name: 'checkpoints',
|
||||
folders: ['/models/checkpoints'],
|
||||
extensions: ['.safetensors']
|
||||
}
|
||||
])
|
||||
await comfyPage.setup()
|
||||
// Force the capability off rather than omitting it: the real backend's
|
||||
// feature_flags handshake would otherwise decide which mode this tests.
|
||||
// Bare-tag backends bucket by bare tags and emit no loader_path, so
|
||||
// names fall back to the filename.
|
||||
await comfyPage.featureFlags.setServerFlags({
|
||||
supports_model_type_tags: false
|
||||
})
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Buckets by bare tags and names leaves from the filename', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
})
|
||||
})
|
||||
73
browser_tests/tests/sidebar/modelLibraryRouting.spec.ts
Normal file
73
browser_tests/tests/sidebar/modelLibraryRouting.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
const assetBrowserModal = '[data-component-id="AssetBrowserModal"]'
|
||||
|
||||
test.describe('Model library tab routing', () => {
|
||||
test('Opens the asset browser when both asset settings are enabled', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mock()
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.tabButton.click()
|
||||
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeHidden()
|
||||
})
|
||||
|
||||
test('Keeps the sidebar tree when the asset API is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// With the asset API off, the browser setting is inert.
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', false)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeHidden()
|
||||
})
|
||||
|
||||
test('Keeps the sidebar tree when only the asset API is enabled', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mock()
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
false
|
||||
)
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Model library tab routing on cloud', { tag: '@cloud' }, () => {
|
||||
test('Defaults to the asset browser', async ({ comfyPage, assetApi }) => {
|
||||
// Cloud defaults both asset settings on; no explicit settings here so the
|
||||
// test pins the defaults, not just the routing.
|
||||
await assetApi.mock()
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.tabButton.click()
|
||||
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.7",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -426,7 +426,7 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it.for(['p-dialog', 'p-select-overlay'])(
|
||||
it.for(['p-dialog', 'p-select-overlay', 'p-toast'])(
|
||||
'focus-outside on a sibling %s portal does not dismiss the parent',
|
||||
(className) => {
|
||||
const overlay = document.createElement('div')
|
||||
@@ -449,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@focus-outside="
|
||||
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
|
||||
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
|
||||
// elements as outside its dialog and would auto-dismiss on the first
|
||||
// interaction, tearing the overlay down mid-interaction. Treat any
|
||||
// PrimeVue overlay click as inside.
|
||||
// PrimeVue Dialogs, Toasts) teleport to body. Reka treats clicks on
|
||||
// body-portaled elements as outside its dialog and would auto-dismiss on the
|
||||
// first interaction, tearing the overlay down mid-interaction. Treat any
|
||||
// PrimeVue overlay click as inside. Toasts matter for focus-outside: when a
|
||||
// button disables itself mid-action (e.g. a confirm entering its loading
|
||||
// state), the browser drops focus and recovery can land on the toast's close
|
||||
// button, which must not dismiss the dialog underneath.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
|
||||
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog, .p-toast'
|
||||
|
||||
// Reka portals its own dialogs / popovers / menus into the body too. When a
|
||||
// nested Reka layer opens on top of a non-modal parent, the parent's
|
||||
@@ -53,7 +56,22 @@ export function onRekaPointerDownOutside(
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
//
|
||||
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
|
||||
// also lose focus to an ordinary app element — not just a portal — when a
|
||||
// nested dialog closes and the element it focused was removed (deleting the
|
||||
// selected row). That programmatic focus shift is not a dismiss intent, so such
|
||||
// a dialog opts out of focus-outside dismissal entirely via
|
||||
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
|
||||
// pointer.
|
||||
export function onRekaFocusOutside(
|
||||
event: OutsideEvent,
|
||||
options: { dismissOnFocusOutside?: boolean } = {}
|
||||
) {
|
||||
if (options.dismissOnFocusOutside === false) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -18,7 +19,10 @@ const {
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
downloadStoreState
|
||||
mockLoadModels,
|
||||
downloadStoreState,
|
||||
settingState,
|
||||
modelsState
|
||||
} = vi.hoisted(() => {
|
||||
let capturedRoot: TreeExplorerNode<unknown> | null = null
|
||||
return {
|
||||
@@ -33,7 +37,13 @@ const {
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
downloadStoreState: { setLastCompleted: (_: unknown) => {} }
|
||||
mockLoadModels: vi.fn().mockResolvedValue([]),
|
||||
downloadStoreState: { setLastCompleted: (_: unknown) => {} },
|
||||
settingState: { useAssetAPI: false, autoLoadAll: false },
|
||||
modelsState: {
|
||||
push: (_: unknown) => {},
|
||||
reset: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -54,20 +64,30 @@ const mockModel = fromPartial<ComfyModelDef>({
|
||||
searchable: 'checkpoints/model.safetensors'
|
||||
})
|
||||
|
||||
vi.mock('@/stores/modelStore', () => ({
|
||||
ResourceState: {
|
||||
Loading: 'loading',
|
||||
Loaded: 'loaded'
|
||||
},
|
||||
useModelStore: () => ({
|
||||
modelFolders: [],
|
||||
models: [mockModel],
|
||||
loadModels: vi.fn().mockResolvedValue([]),
|
||||
loadModelFolders: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshModelFolder: mockRefreshModelFolder
|
||||
})
|
||||
}))
|
||||
vi.mock('@/stores/modelStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const models = reactive<ComfyModelDef[]>([])
|
||||
modelsState.push = (model: unknown) => {
|
||||
models.push(model as ComfyModelDef)
|
||||
}
|
||||
modelsState.reset = () => {
|
||||
models.splice(0, models.length, mockModel)
|
||||
}
|
||||
return {
|
||||
ResourceState: {
|
||||
Loading: 'loading',
|
||||
Loaded: 'loaded'
|
||||
},
|
||||
useModelStore: () => ({
|
||||
modelFolders: [],
|
||||
models,
|
||||
loadModels: mockLoadModels,
|
||||
loadModelFolders: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshModelFolder: mockRefreshModelFolder
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/assetDownloadStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
@@ -92,6 +112,10 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.ModelLibrary.NameFormat') return 'filename'
|
||||
if (key === 'Comfy.Assets.UseAssetAPI') return settingState.useAssetAPI
|
||||
if (key === 'Comfy.ModelLibrary.AutoLoadAll') {
|
||||
return settingState.autoLoadAll
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
@@ -104,26 +128,45 @@ vi.mock('@/composables/useTreeExpansion', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/TreeExplorer.vue', () => ({
|
||||
default: {
|
||||
name: 'TreeExplorer',
|
||||
template: '<div data-testid="tree-explorer" />',
|
||||
props: ['root', 'expandedKeys'],
|
||||
setup(props: { root: TreeExplorerNode<unknown> }) {
|
||||
captureRoot(props.root)
|
||||
vi.mock('@/components/common/TreeExplorer.vue', async () => {
|
||||
const { watchEffect } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'TreeExplorer',
|
||||
template: '<div data-testid="tree-explorer" />',
|
||||
props: ['root', 'expandedKeys'],
|
||||
setup(props: { root: TreeExplorerNode<unknown> }) {
|
||||
watchEffect(() => captureRoot(props.root))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchInput',
|
||||
template: '<input data-testid="search-input" />',
|
||||
template: '<input data-testid="search-input" @input="onInput" />',
|
||||
props: ['modelValue', 'placeholder'],
|
||||
setup() {
|
||||
return { focus: vi.fn() }
|
||||
},
|
||||
expose: ['focus']
|
||||
emits: ['update:modelValue', 'search'],
|
||||
setup(
|
||||
_props: unknown,
|
||||
{
|
||||
emit,
|
||||
expose
|
||||
}: {
|
||||
emit: (event: 'update:modelValue' | 'search', value: string) => void
|
||||
expose: (exposed: Record<string, unknown>) => void
|
||||
}
|
||||
) {
|
||||
expose({ focus: vi.fn() })
|
||||
return {
|
||||
onInput: (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
emit('update:modelValue', value)
|
||||
emit('search', value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -134,7 +177,8 @@ vi.mock('./SidebarTopArea.vue', () => ({
|
||||
vi.mock('./SidebarTabTemplate.vue', () => ({
|
||||
default: {
|
||||
name: 'SidebarTabTemplate',
|
||||
template: '<div><slot name="header" /><slot name="body" /></div>'
|
||||
template:
|
||||
'<div><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -157,13 +201,17 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
vi.clearAllMocks()
|
||||
resetRoot()
|
||||
downloadStoreState.setLastCompleted(null)
|
||||
settingState.useAssetAPI = false
|
||||
settingState.autoLoadAll = false
|
||||
modelsState.reset()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
return render(ModelLibrarySidebarTab, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false }), i18n],
|
||||
stubs: { teleport: true }
|
||||
stubs: { teleport: true },
|
||||
directives: { tooltip: {} }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -236,4 +284,67 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
|
||||
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('updates active search results when a reload adds a matching model', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
await user.type(screen.getByTestId('search-input'), 'model')
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoadModels).toHaveBeenCalled()
|
||||
const leafLabels = () => {
|
||||
const { children: folders = [] } = getRoot()
|
||||
return folders.flatMap(({ children: leaves = [] }) =>
|
||||
leaves.map((leaf) => leaf.label)
|
||||
)
|
||||
}
|
||||
expect(leafLabels()).toEqual(['model'])
|
||||
|
||||
// A completed scan reloads the store while the search is still active.
|
||||
modelsState.push(
|
||||
fromPartial<ComfyModelDef>({
|
||||
key: 'checkpoints/model-new.safetensors',
|
||||
file_name: 'model-new.safetensors',
|
||||
simplified_file_name: 'model-new',
|
||||
title: 'Model New',
|
||||
directory: 'checkpoints',
|
||||
searchable: 'checkpoints/model-new.safetensors'
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(leafLabels()).toEqual(['model', 'model-new'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('asset mode', () => {
|
||||
it('hides the load-all button and eager-loads models on mount', async () => {
|
||||
settingState.useAssetAPI = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByLabelText('g.loadAllFolders')).toBeNull()
|
||||
expect(screen.getByLabelText('g.refresh')).toBeInTheDocument()
|
||||
expect(mockLoadModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('legacy mode keeps the load-all button and stays lazy by default', async () => {
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByLabelText('g.loadAllFolders')).toBeInTheDocument()
|
||||
expect(mockLoadModels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('legacy mode still honors AutoLoadAll', async () => {
|
||||
settingState.autoLoadAll = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoadModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!usesAssetAPI"
|
||||
v-tooltip.bottom="$t('g.loadAllFolders')"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
@@ -77,28 +78,28 @@ import { buildTree } from '@/utils/treeUtil'
|
||||
const modelStore = useModelStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const usesAssetAPI = computed(() =>
|
||||
settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
)
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const searchBoxRef = ref()
|
||||
const searchQuery = ref<string>('')
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
const filteredModels = ref<ComfyModelDef[]>([])
|
||||
const filteredModels = computed<ComfyModelDef[]>(() => {
|
||||
const search = searchQuery.value.toLocaleLowerCase()
|
||||
if (!search) return []
|
||||
return modelStore.models.filter((model) => model.searchable.includes(search))
|
||||
})
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query) {
|
||||
filteredModels.value = []
|
||||
expandedKeys.value = {}
|
||||
return
|
||||
}
|
||||
// Load all models to ensure we have the latest data
|
||||
// Load all models to ensure results cover folders not yet opened
|
||||
await modelStore.loadModels()
|
||||
const search = query.toLocaleLowerCase()
|
||||
filteredModels.value = modelStore.models.filter((model: ComfyModelDef) => {
|
||||
return model.searchable.includes(search)
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expandNode(root.value)
|
||||
}
|
||||
|
||||
type ModelOrFolder = ComfyModelDef | ModelFolder
|
||||
@@ -112,6 +113,12 @@ const root = computed<TreeNode>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
watch(root, async (newRoot) => {
|
||||
if (!searchQuery.value) return
|
||||
await nextTick()
|
||||
expandNode(newRoot)
|
||||
})
|
||||
|
||||
const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
|
||||
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ModelOrFolder> => {
|
||||
@@ -193,7 +200,13 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
|
||||
// In asset mode the whole library resolves from one cached walk, so eager
|
||||
// loading is cheap and keeps search and folder badges complete from the
|
||||
// start; AutoLoadAll remains the opt-in for the request-per-folder legacy path.
|
||||
if (
|
||||
usesAssetAPI.value ||
|
||||
settingStore.get('Comfy.ModelLibrary.AutoLoadAll')
|
||||
) {
|
||||
await modelStore.loadModels()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ComfyModelDef } from '@/stores/modelStore'
|
||||
import { getModelPreviewUrl } from '@/stores/modelStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import ModelPreview from './ModelPreview.vue'
|
||||
@@ -37,17 +38,7 @@ const props = defineProps<{
|
||||
// Note: The leaf node should always have a model definition on node.data.
|
||||
const modelDef = computed<ComfyModelDef>(() => props.node.data!)
|
||||
|
||||
const modelPreviewUrl = computed(() => {
|
||||
if (modelDef.value.image) {
|
||||
return modelDef.value.image
|
||||
}
|
||||
const folder = modelDef.value.directory
|
||||
const path_index = modelDef.value.path_index
|
||||
const extension = modelDef.value.file_name.split('.').pop()
|
||||
const filename = modelDef.value.file_name.replace(`.${extension}`, '.webp')
|
||||
const encodedFilename = encodeURIComponent(filename).replace(/%2F/g, '/')
|
||||
return `/api/experiment/models/preview/${folder}/${path_index}/${encodedFilename}`
|
||||
})
|
||||
const modelPreviewUrl = computed(() => getModelPreviewUrl(modelDef.value))
|
||||
|
||||
const previewRef = ref<InstanceType<typeof ModelPreview> | null>(null)
|
||||
const modelPreviewStyle = ref<CSSProperties>({
|
||||
|
||||
@@ -33,7 +33,8 @@ export enum ServerFeatureFlag {
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile',
|
||||
SUPPORTS_MODEL_TYPE_TAGS = 'supports_model_type_tags'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +209,12 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
},
|
||||
get supportsModelTypeTags() {
|
||||
return api.getServerFeature(
|
||||
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
updateTextPreviewWidgets
|
||||
} from '@/extensions/core/textPreviewWidgets'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.PreviewAny',
|
||||
@@ -30,5 +32,11 @@ useExtensionService().registerExtension({
|
||||
onExecuted?.apply(this, [message])
|
||||
updateTextPreviewWidgets(this, message)
|
||||
}
|
||||
},
|
||||
onNodeOutputsUpdated(nodeOutputs) {
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
|
||||
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3122,57 +3122,52 @@
|
||||
"cloudOnboarding": {
|
||||
"skipToCloudApp": "Skip to the cloud app",
|
||||
"survey": {
|
||||
"title": "Cloud Survey",
|
||||
"title": "Let's get to know you",
|
||||
"placeholder": "Survey questions placeholder",
|
||||
"intro": "Help us tailor your ComfyUI experience.",
|
||||
"intro": "A few quick questions so we can set up ComfyUI for you.",
|
||||
"otherPlaceholder": "Tell us more",
|
||||
"errors": {
|
||||
"chooseAnOption": "Please choose an option.",
|
||||
"selectAtLeastOne": "Please select at least one option.",
|
||||
"describeAnswer": "Please describe your answer."
|
||||
},
|
||||
"steps": {
|
||||
"usage": "How do you plan to use ComfyUI?",
|
||||
"familiarity": "How familiar are you with ComfyUI?",
|
||||
"intent": "What do you want to create with ComfyUI?",
|
||||
"source": "Where did you hear about ComfyUI?"
|
||||
"describeAnswer": "Please describe your answer.",
|
||||
"answerTooLong": "Please keep your answer under {max} characters."
|
||||
},
|
||||
"options": {
|
||||
"usage": {
|
||||
"personal": "Personal use",
|
||||
"work": "Work",
|
||||
"education": "Education (student or educator)"
|
||||
},
|
||||
"familiarity": {
|
||||
"new": "New — never used it",
|
||||
"starting": "Beginner — following tutorials",
|
||||
"basics": "Intermediate — comfortable with basics",
|
||||
"advanced": "Advanced — build and edit workflows",
|
||||
"expert": "Expert — I help others"
|
||||
},
|
||||
"intent": {
|
||||
"workflows": "Custom workflows or pipelines",
|
||||
"custom_nodes": "Custom nodes",
|
||||
"videos": "Videos",
|
||||
"images": "Images",
|
||||
"3d_game": "3D assets / game assets",
|
||||
"audio": "Audio / music",
|
||||
"apps": "Simplified Apps from workflows",
|
||||
"api": "API endpoints to run workflows",
|
||||
"not_sure": "Not sure"
|
||||
"video": "Video",
|
||||
"workflows": "Workflows and pipelines",
|
||||
"apps_api": "Apps and APIs",
|
||||
"exploring": "Just exploring",
|
||||
"other": "Something else",
|
||||
"otherPlaceholder": "What do you want to make?"
|
||||
},
|
||||
"experience": {
|
||||
"new": "New to ComfyUI",
|
||||
"some": "I know my way around",
|
||||
"pro": "I'm a power user"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Custom nodes",
|
||||
"pipelines": "Automated pipelines",
|
||||
"products": "Products for others"
|
||||
},
|
||||
"source": {
|
||||
"social": "Social media",
|
||||
"friend": "A friend or colleague",
|
||||
"search": "Web search",
|
||||
"community": "A community or forum",
|
||||
"other": "Somewhere else",
|
||||
"otherPlaceholder": "Where did you find us?"
|
||||
},
|
||||
"source_social": {
|
||||
"youtube": "YouTube",
|
||||
"reddit": "Reddit",
|
||||
"twitter": "Twitter / X",
|
||||
"twitter": "X (Twitter)",
|
||||
"instagram": "Instagram",
|
||||
"tiktok": "TikTok",
|
||||
"linkedin": "LinkedIn",
|
||||
"friend": "Friend or colleague",
|
||||
"search": "Google / search",
|
||||
"newsletter": "Newsletter or blog",
|
||||
"conference": "Conference or event",
|
||||
"discord": "Discord / community",
|
||||
"github": "GitHub",
|
||||
"other": "Other"
|
||||
"discord": "Discord"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3265,10 +3260,11 @@
|
||||
"cloudForgotPassword_emailRequired": "Email is required",
|
||||
"cloudForgotPassword_passwordResetSent": "Password reset sent",
|
||||
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
|
||||
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
|
||||
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "What do you want to make?",
|
||||
"cloudSurvey_steps_experience": "How well do you know ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "What are you building?",
|
||||
"cloudSurvey_steps_source": "How did you find us?",
|
||||
"cloudSurvey_steps_source_social": "Which platform?",
|
||||
"assetBrowser": {
|
||||
"allCategory": "All {category}",
|
||||
"allModels": "All Models",
|
||||
|
||||
@@ -9,6 +9,20 @@ import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
const mockAssetsByKey = vi.hoisted(() => new Map<string, AssetItem[]>())
|
||||
const mockLoadingByKey = vi.hoisted(() => new Map<string, boolean>())
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) =>
|
||||
@@ -214,6 +228,7 @@ describe('AssetBrowserModal', () => {
|
||||
vi.resetAllMocks()
|
||||
mockAssetsByKey.clear()
|
||||
mockLoadingByKey.clear()
|
||||
mockSupportsModelTypeTags.value = false
|
||||
})
|
||||
|
||||
describe('Integration with useAssetBrowser', () => {
|
||||
@@ -420,5 +435,20 @@ describe('AssetBrowserModal', () => {
|
||||
'assetBrowser.allCategory:{"category":"Checkpoints"}'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix from the title when the flag is on', async () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'Model A', 'model_type:checkpoints')
|
||||
]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
renderModal({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe(
|
||||
'assetBrowser.allCategory:{"category":"Checkpoints"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,6 +100,7 @@ import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
@@ -109,12 +110,14 @@ import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getPrimaryCategoryTag } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const assetStore = useAssetsStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
@@ -191,9 +194,21 @@ const focusedAsset = ref<AssetDisplayItem | null>(null)
|
||||
const isRightPanelOpen = ref(false)
|
||||
|
||||
const primaryCategoryTag = computed(() => {
|
||||
const modelTypeMode = flags.supportsModelTypeTags
|
||||
// A node-typed picker is FOR a category; title off that category rather
|
||||
// than guessing from the first asset, whose first model_type value may be
|
||||
// a different category it shares a root with.
|
||||
if (modelTypeMode && props.nodeType) {
|
||||
const mapped = modelToNodeStore.getCategoryForNodeType(props.nodeType)
|
||||
if (mapped) return mapped
|
||||
}
|
||||
|
||||
const assets = fetchedAssets.value ?? []
|
||||
// Covered assets title off the model_type value they group under (so title
|
||||
// and grouping cannot diverge); uncovered assets keep the legacy verbatim
|
||||
// first tag.
|
||||
const tagFromAssets = assets
|
||||
.map((asset) => asset.tags?.find((tag) => tag !== 'models'))
|
||||
.map((asset) => getPrimaryCategoryTag(asset, modelTypeMode))
|
||||
.find((tag): tag is string => typeof tag === 'string' && tag.length > 0)
|
||||
|
||||
if (tagFromAssets) return tagFromAssets
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
@@ -14,6 +14,14 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockDistribution = vi.hoisted(() => ({ isCloud: false }))
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
get isCloud() {
|
||||
return mockDistribution.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -41,6 +49,10 @@ describe('ModelInfoPanel', () => {
|
||||
...overrides
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockDistribution.isCloud = false
|
||||
})
|
||||
|
||||
function renderPanel(asset: AssetDisplayItem) {
|
||||
return render(ModelInfoPanel, {
|
||||
props: { asset },
|
||||
@@ -138,6 +150,18 @@ describe('ModelInfoPanel', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an editable model type dropdown for a mutable asset on cloud', () => {
|
||||
mockDistribution.isCloud = true
|
||||
renderPanel(createMockAsset({ is_immutable: false }))
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the model type read-only on core even for a mutable asset', () => {
|
||||
mockDistribution.isCloud = false
|
||||
renderPanel(createMockAsset({ is_immutable: false }))
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders base models field', () => {
|
||||
const asset = createMockAsset({
|
||||
user_metadata: { base_model: ['SDXL'] }
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</span>
|
||||
</template>
|
||||
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
|
||||
<Select v-if="!isImmutable" v-model="selectedModelType">
|
||||
<Select v-if="isModelTypeEditable" v-model="selectedModelType">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue
|
||||
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
|
||||
@@ -215,6 +215,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
@@ -229,17 +230,19 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
buildModelTypeTagUpdate,
|
||||
getAssetAdditionalTags,
|
||||
getAssetBaseModels,
|
||||
getAssetDescription,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getEditableModelType,
|
||||
getSourceName
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -265,6 +268,7 @@ const { asset, cacheKey, selectContentStyle } = defineProps<{
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { modelTypes } = useModelTypes()
|
||||
|
||||
const pendingUpdates = ref<AssetUserMetadata>({})
|
||||
@@ -272,6 +276,9 @@ const pendingModelType = ref<string | undefined>(undefined)
|
||||
const isEditingDisplayName = ref(false)
|
||||
|
||||
const isImmutable = computed(() => asset.is_immutable ?? true)
|
||||
// Retagging a model rewrites its asset tags; core is filesystem-backed and does
|
||||
// not yet move the file to match, so the model type is read-only off-cloud.
|
||||
const isModelTypeEditable = computed(() => !isImmutable.value && isCloud)
|
||||
const displayName = computed(
|
||||
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
|
||||
)
|
||||
@@ -318,12 +325,17 @@ function handleDisplayNameEdit(newName: string) {
|
||||
}
|
||||
|
||||
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
|
||||
if (isImmutable.value) return
|
||||
const currentModelType = getAssetModelType(asset)
|
||||
if (!isModelTypeEditable.value) return
|
||||
const currentModelType = getEditableModelType(
|
||||
asset,
|
||||
flags.supportsModelTypeTags
|
||||
)
|
||||
if (currentModelType === newModelType) return
|
||||
const newTags = asset.tags
|
||||
.filter((tag) => tag !== currentModelType)
|
||||
.concat(newModelType)
|
||||
const newTags = buildModelTypeTagUpdate(
|
||||
asset,
|
||||
newModelType,
|
||||
flags.supportsModelTypeTags
|
||||
)
|
||||
assetsStore.updateAssetTags(asset, newTags, cacheKey)
|
||||
}, 500)
|
||||
|
||||
@@ -345,7 +357,10 @@ const userDescription = computed({
|
||||
})
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
|
||||
get: () =>
|
||||
pendingModelType.value ??
|
||||
getEditableModelType(asset, flags.supportsModelTypeTags) ??
|
||||
undefined,
|
||||
set: (value: string | undefined) => {
|
||||
if (!value) return
|
||||
pendingModelType.value = value
|
||||
|
||||
@@ -25,10 +25,22 @@ vi.mock('@/i18n', () => ({
|
||||
d: (date: Date) => date.toLocaleDateString()
|
||||
}))
|
||||
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetBrowser', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
mockSupportsModelTypeTags.value = false
|
||||
})
|
||||
|
||||
// Test fixtures - minimal data focused on functionality being tested
|
||||
@@ -138,6 +150,25 @@ describe('useAssetBrowser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix from the badge when the flag is on', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
const apiAsset = createApiAsset({
|
||||
tags: ['models', 'model_type:checkpoints', 'sdxl']
|
||||
})
|
||||
|
||||
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
|
||||
const result = filteredAssets.value[0]
|
||||
|
||||
expect(result.badges).toContainEqual({
|
||||
label: 'checkpoints',
|
||||
type: 'type'
|
||||
})
|
||||
expect(result.badges).not.toContainEqual({
|
||||
label: 'model_type:checkpoints',
|
||||
type: 'type'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles tags with multiple slashes in badges', () => {
|
||||
const apiAsset = createApiAsset({
|
||||
tags: ['models', 'checkpoint/subfolder/model-name']
|
||||
@@ -668,6 +699,34 @@ describe('useAssetBrowser', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('groups by model_type:* value and ignores other tags when the flag is on', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['models', 'model_type:checkpoints', 'sdxl'] }),
|
||||
createApiAsset({ tags: ['models', 'model_type:LLM'] })
|
||||
]
|
||||
|
||||
const { navItems } = useAssetBrowser(ref(assets))
|
||||
|
||||
const typeGroup = navItems.value[2] as { items: { id: string }[] }
|
||||
expect(typeGroup.items.map((i) => i.id)).toEqual(['LLM', 'checkpoints'])
|
||||
})
|
||||
|
||||
it('ignores model_type: and groups by bare tags when the flag is off', () => {
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['models', 'model_type:checkpoints'] }),
|
||||
createApiAsset({ tags: ['models', 'model_type:LLM'] })
|
||||
]
|
||||
|
||||
const { navItems } = useAssetBrowser(ref(assets))
|
||||
|
||||
const typeGroup = navItems.value[2] as { items: { id: string }[] }
|
||||
expect(typeGroup.items.map((i) => i.id)).toEqual([
|
||||
'model_type:LLM',
|
||||
'model_type:checkpoints'
|
||||
])
|
||||
})
|
||||
|
||||
it('handles assets with no category tag', () => {
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['models'] }), // No second tag
|
||||
|
||||
@@ -19,10 +19,13 @@ import {
|
||||
} from '@/platform/assets/utils/assetFilterUtils'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetFilename
|
||||
getAssetCategories,
|
||||
getAssetFilename,
|
||||
getAssetTypeBadges
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { MODELS_TAG } from '@/platform/assets/services/assetService'
|
||||
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
|
||||
@@ -43,18 +46,19 @@ export interface AssetDisplayItem extends AssetItem {
|
||||
}
|
||||
}
|
||||
|
||||
const displayItemCache = new WeakMap<AssetItem, AssetDisplayItem>()
|
||||
const displayItemCache = new WeakMap<
|
||||
AssetItem,
|
||||
{ modelTypeMode: boolean; item: AssetDisplayItem }
|
||||
>()
|
||||
|
||||
function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
|
||||
function buildDisplayItem(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): AssetDisplayItem {
|
||||
const badges: AssetBadge[] = []
|
||||
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
if (typeTag) {
|
||||
const badgeLabel = typeTag.includes('/')
|
||||
? typeTag.substring(typeTag.indexOf('/') + 1)
|
||||
: typeTag
|
||||
|
||||
badges.push({ label: badgeLabel, type: 'type' })
|
||||
for (const typeBadge of getAssetTypeBadges(asset, modelTypeMode)) {
|
||||
badges.push({ label: typeBadge, type: 'type' })
|
||||
}
|
||||
|
||||
for (const model of getAssetBaseModels(asset)) {
|
||||
@@ -75,12 +79,15 @@ function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
|
||||
}
|
||||
}
|
||||
|
||||
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||
function transformAssetForDisplay(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): AssetDisplayItem {
|
||||
const cached = displayItemCache.get(asset)
|
||||
if (cached) return cached
|
||||
const built = buildDisplayItem(asset)
|
||||
displayItemCache.set(asset, built)
|
||||
return built
|
||||
if (cached && cached.modelTypeMode === modelTypeMode) return cached.item
|
||||
const item = buildDisplayItem(asset, modelTypeMode)
|
||||
displayItemCache.set(asset, { modelTypeMode, item })
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +100,7 @@ export function useAssetBrowser(
|
||||
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const { sessionDownloadCount } = storeToRefs(assetDownloadStore)
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// State
|
||||
const searchQuery = ref('')
|
||||
@@ -122,12 +130,10 @@ export function useAssetBrowser(
|
||||
})
|
||||
|
||||
const typeCategories = computed<NavItemData[]>(() => {
|
||||
const modelTypeMode = flags.supportsModelTypeTags
|
||||
const categories = assets.value
|
||||
.filter((asset) => asset.tags.includes(MODELS_TAG))
|
||||
.flatMap((asset) =>
|
||||
asset.tags.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
|
||||
)
|
||||
.map((tag) => tag.split('/')[0])
|
||||
.flatMap((asset) => getAssetCategories(asset, modelTypeMode))
|
||||
|
||||
return Array.from(new Set(categories))
|
||||
.sort()
|
||||
@@ -191,7 +197,9 @@ export function useAssetBrowser(
|
||||
|
||||
// Category-filtered assets for filter options (before search/format/base model filters)
|
||||
const categoryFilteredAssets = computed(() => {
|
||||
return assets.value.filter(filterByCategory(selectedCategory.value))
|
||||
return assets.value.filter(
|
||||
filterByCategory(selectedCategory.value, flags.supportsModelTypeTags)
|
||||
)
|
||||
})
|
||||
|
||||
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
|
||||
@@ -248,7 +256,10 @@ export function useAssetBrowser(
|
||||
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
|
||||
|
||||
// Transform to display format
|
||||
return sortedAssets.map(transformAssetForDisplay)
|
||||
const modelTypeMode = flags.supportsModelTypeTags
|
||||
return sortedAssets.map((asset) =>
|
||||
transformAssetForDisplay(asset, modelTypeMode)
|
||||
)
|
||||
})
|
||||
|
||||
function updateFilters(newFilters: AssetFilterState) {
|
||||
|
||||
@@ -38,7 +38,10 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => path)
|
||||
apiURL: vi.fn((path: string) => path),
|
||||
getServerFeature: vi.fn(
|
||||
(_name: string, defaultValue?: unknown) => defaultValue
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -279,6 +282,43 @@ describe('useUploadModelWizard', () => {
|
||||
expect(result?.modelType).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('namespaces the tag but keeps user_metadata.model_type bare when the backend supports it', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}
|
||||
})
|
||||
vi.mocked(api.getServerFeature).mockImplementation((name, defaultValue) =>
|
||||
name === 'supports_model_type_tags' ? true : defaultValue
|
||||
)
|
||||
|
||||
try {
|
||||
const wizard = setupUploadModelWizard(modelTypes, {
|
||||
requiredModelType: 'checkpoints'
|
||||
})
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
await wizard.uploadModel()
|
||||
|
||||
const uploadArg = vi.mocked(assetService.uploadAssetAsync).mock
|
||||
.calls[0][0]
|
||||
expect(uploadArg.tags).toEqual(['models', 'model_type:checkpoints'])
|
||||
expect(uploadArg.user_metadata?.model_type).toBe('checkpoints')
|
||||
// The namespaced returned tag must not trip the required-type guard.
|
||||
expect(wizard.uploadTypeMismatch.value).toBeNull()
|
||||
} finally {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_name, defaultValue) => defaultValue
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the synced asset filename for sync imports', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
@@ -347,6 +387,65 @@ describe('useUploadModelWizard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a namespaced model_type: tag as satisfying the required type', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(
|
||||
ref([
|
||||
{ name: 'Checkpoint', value: 'checkpoints' },
|
||||
{ name: 'LoRA', value: 'loras' }
|
||||
]),
|
||||
{ requiredModelType: 'checkpoints' }
|
||||
)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(wizard.uploadTypeMismatch.value).toBeNull()
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix from the imported-type label on a real mismatch', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-lora',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'model_type:loras']
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(
|
||||
ref([
|
||||
{ name: 'Checkpoint', value: 'checkpoints' },
|
||||
{ name: 'LoRA', value: 'loras' }
|
||||
]),
|
||||
{ requiredModelType: 'checkpoints' }
|
||||
)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(wizard.uploadTypeMismatch.value).toEqual({
|
||||
importedModelType: 'loras',
|
||||
importedModelTypeLabel: 'LoRA',
|
||||
requiredModelType: 'checkpoints',
|
||||
requiredModelTypeLabel: 'Checkpoint'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not block sync imports as mismatches without a required model type', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { st } from '@/i18n'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
@@ -11,7 +12,11 @@ import type {
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { ImportSource } from '@/platform/assets/types/importSource'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetFilename,
|
||||
stripModelTypePrefix,
|
||||
toModelTypeTag
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -68,6 +73,7 @@ export function useUploadModelWizard(
|
||||
options: UploadModelWizardOptions = {}
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
@@ -271,19 +277,22 @@ export function useUploadModelWizard(
|
||||
}
|
||||
|
||||
function getImportedModelType(asset: AssetItem): string | undefined {
|
||||
const knownType = asset.tags.find(
|
||||
(tag) =>
|
||||
tag !== MODEL_ROOT_TAG &&
|
||||
const subtypeTags = asset.tags
|
||||
.filter((tag) => tag !== MODEL_ROOT_TAG)
|
||||
.map(stripModelTypePrefix)
|
||||
return (
|
||||
subtypeTags.find((tag) =>
|
||||
modelTypes.value.some((type) => type.value === tag)
|
||||
) ?? subtypeTags[0]
|
||||
)
|
||||
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
|
||||
}
|
||||
|
||||
function blockMismatchedImportedModel(
|
||||
asset: AssetItem,
|
||||
requiredType: string
|
||||
): boolean {
|
||||
if (asset.tags.includes(requiredType)) return false
|
||||
if (asset.tags.map(stripModelTypePrefix).includes(requiredType))
|
||||
return false
|
||||
|
||||
const importedType = getImportedModelType(asset)
|
||||
uploadStatus.value = 'error'
|
||||
@@ -317,7 +326,11 @@ export function useUploadModelWizard(
|
||||
|
||||
try {
|
||||
const modelType = resolvedModelType.value
|
||||
const tags = modelType ? ['models', modelType] : ['models']
|
||||
const subtypeTag =
|
||||
modelType && flags.supportsModelTypeTags
|
||||
? toModelTypeTag(modelType)
|
||||
: modelType
|
||||
const tags = subtypeTag ? [MODEL_ROOT_TAG, subtypeTag] : [MODEL_ROOT_TAG]
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
|
||||
@@ -11,6 +11,8 @@ const zAsset = z.object({
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
display_name: z.string().optional(),
|
||||
/** Path within the model's category folder, i.e. the value a loader widget expects. */
|
||||
loader_path: z.string().nullish(),
|
||||
preview_url: z.string().optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
@@ -27,11 +29,6 @@ const zAssetResponse = zListAssetsResponse
|
||||
assets: z.array(zAsset)
|
||||
})
|
||||
|
||||
const zModelFolder = z.object({
|
||||
name: z.string(),
|
||||
folders: z.array(z.string())
|
||||
})
|
||||
|
||||
// Zod schema for ModelFile to align with interface
|
||||
const zModelFile = z.object({
|
||||
name: z.string(),
|
||||
@@ -100,7 +97,6 @@ export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type AssetMetadata = z.infer<typeof zAssetMetadata>
|
||||
export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
/** Payload for updating an asset via PUT /assets/:id */
|
||||
@@ -132,4 +128,10 @@ export type TagsOperationResult = z.infer<typeof tagsOperationResultSchema>
|
||||
export interface ModelFolderInfo {
|
||||
name: string
|
||||
folders: string[]
|
||||
/**
|
||||
* The folder's raw registered extension allowlist from
|
||||
* `/experiment/models`. An empty array means match-all; absent on older
|
||||
* backends.
|
||||
*/
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { api } from '@/scripts/api'
|
||||
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: true }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -19,6 +20,16 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
@@ -40,7 +51,9 @@ vi.mock('@/stores/modelToNodeStore', () => {
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
fetchApi: vi.fn(),
|
||||
addCustomEventListener: vi.fn(),
|
||||
removeCustomEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -87,6 +100,7 @@ function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'model.safetensors',
|
||||
loader_path: overrides.name ?? 'model.safetensors',
|
||||
tags: ['models'],
|
||||
...overrides
|
||||
}
|
||||
@@ -416,32 +430,329 @@ describe(assetService.deleteAsset, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.getAssetModelFolders, () => {
|
||||
describe(assetService.getAssetModels, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateModelBuckets()
|
||||
mockSupportsModelTypeTags.value = true
|
||||
})
|
||||
|
||||
it('walks the models tag once, excluding missing assets', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'a', tags: ['models', 'model_type:checkpoints'] })
|
||||
])
|
||||
)
|
||||
|
||||
await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(1)
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.get('include_tags')).toBe('models')
|
||||
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
})
|
||||
|
||||
it('buckets by bare tags when model_type tags are unsupported', async () => {
|
||||
mockSupportsModelTypeTags.value = false
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'a',
|
||||
name: 'a.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([{ name: 'a.safetensors', pathIndex: 0 }])
|
||||
})
|
||||
|
||||
it('drops uncategorized model assets with a warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'ok',
|
||||
name: 'ok.safetensors',
|
||||
tags: ['models', 'model_type:loras']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'uncat',
|
||||
name: 'orphan.safetensors',
|
||||
tags: ['models']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const loras = await assetService.getAssetModels('loras')
|
||||
|
||||
expect(loras).toEqual([{ name: 'ok.safetensors', pathIndex: 0 }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('orphan.safetensors')
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('maps loader_path and drops unloadable assets without one', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'nested',
|
||||
name: 'model.safetensors',
|
||||
loader_path: 'sdxl/model.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'orphan',
|
||||
name: 'orphan.safetensors',
|
||||
loader_path: null,
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'other-folder',
|
||||
name: 'lora.safetensors',
|
||||
tags: ['models', 'model_type:loras']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([{ name: 'sdxl/model.safetensors', pathIndex: 0 }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('orphan.safetensors')
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('drops assets whose loader path is traversal-shaped', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'evil',
|
||||
name: 'evil.safetensors',
|
||||
loader_path: '../../secrets/evil.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'ok',
|
||||
name: 'fine.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([{ name: 'fine.safetensors', pathIndex: 0 }])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unsafe'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('groups slashed bare tags by their top-level segment', async () => {
|
||||
mockSupportsModelTypeTags.value = false
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'slashed',
|
||||
name: 'model1.safetensors',
|
||||
tags: ['models', 'Chatterbox/subfolder1/model1']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('Chatterbox')
|
||||
|
||||
expect(models).toEqual([{ name: 'model1.safetensors', pathIndex: 0 }])
|
||||
})
|
||||
|
||||
it('falls back to filename metadata then name on bare-tag backends', async () => {
|
||||
mockSupportsModelTypeTags.value = false
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'cloud-hash',
|
||||
name: 'blake3-content-hash',
|
||||
loader_path: null,
|
||||
user_metadata: { filename: 'sdxl/cloud-model.safetensors' },
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'bare',
|
||||
name: 'plain.safetensors',
|
||||
loader_path: null,
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([
|
||||
{ name: 'sdxl/cloud-model.safetensors', pathIndex: 0 },
|
||||
{ name: 'plain.safetensors', pathIndex: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it('orders each folder subdirectories-first then files, alphabetically', async () => {
|
||||
const checkpointAsset = (id: string, loaderPath: string) =>
|
||||
validAsset({
|
||||
id,
|
||||
name: loaderPath.split('/').pop()!,
|
||||
loader_path: loaderPath,
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
checkpointAsset('1', 'sdxl/base.safetensors'),
|
||||
checkpointAsset('2', 'v1-5.safetensors'),
|
||||
checkpointAsset('3', 'sdxl/refiner.safetensors'),
|
||||
checkpointAsset('4', 'anything.safetensors'),
|
||||
checkpointAsset('5', 'dynamicrafter/model.safetensors')
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models.map((m) => m.name)).toEqual([
|
||||
'dynamicrafter/model.safetensors',
|
||||
'sdxl/base.safetensors',
|
||||
'sdxl/refiner.safetensors',
|
||||
'anything.safetensors',
|
||||
'v1-5.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a stale in-flight walk overwrite an invalidated cache', async () => {
|
||||
let resolveStaleWalk!: (response: Response) => void
|
||||
fetchApiMock.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveStaleWalk = resolve
|
||||
})
|
||||
)
|
||||
const staleRead = assetService.getAssetModels('checkpoints')
|
||||
|
||||
assetService.invalidateModelBuckets()
|
||||
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'fresh',
|
||||
name: 'fresh.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
const freshModels = await assetService.getAssetModels('checkpoints')
|
||||
expect(freshModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
|
||||
|
||||
resolveStaleWalk(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'stale',
|
||||
name: 'stale.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
await staleRead
|
||||
|
||||
const cachedModels = await assetService.getAssetModels('checkpoints')
|
||||
expect(cachedModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('places multi-category assets in every folder from a single walk', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'shared',
|
||||
name: 'dual_use.safetensors',
|
||||
loader_path: 'dual_use.safetensors',
|
||||
tags: [
|
||||
'models',
|
||||
'model_type:checkpoints',
|
||||
'model_type:diffusion_models'
|
||||
]
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const checkpoints = await assetService.getAssetModels('checkpoints')
|
||||
const diffusion = await assetService.getAssetModels('diffusion_models')
|
||||
|
||||
expect(checkpoints).toEqual([
|
||||
{ name: 'dual_use.safetensors', pathIndex: 0 }
|
||||
])
|
||||
expect(diffusion).toEqual([{ name: 'dual_use.safetensors', pathIndex: 0 }])
|
||||
// Both folder reads resolve from a single memoized models walk.
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.onModelsScanned, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
|
||||
it('invokes the callback when the scan event fires and unsubscribes cleanly', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
const unsubscribe = assetService.onModelsScanned(callback)
|
||||
|
||||
const [eventType, handler] = vi.mocked(api.addCustomEventListener).mock
|
||||
.calls[0]!
|
||||
expect(eventType).toBe('assets.seed.fast_complete')
|
||||
|
||||
handler!(new CustomEvent(eventType))
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
|
||||
unsubscribe()
|
||||
expect(api.removeCustomEventListener).toHaveBeenCalledWith(
|
||||
eventType,
|
||||
handler
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.seedModelAssets, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('POSTs the models root to the seed endpoint', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
])
|
||||
buildResponse({ status: 'started' }, { status: 202 })
|
||||
)
|
||||
|
||||
const folders = await assetService.getAssetModelFolders()
|
||||
await assetService.seedModelAssets()
|
||||
|
||||
expect(folders).toEqual([
|
||||
{ name: 'checkpoints', folders: [] },
|
||||
{ name: 'loras', folders: [] }
|
||||
])
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/assets/seed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roots: ['models'] })
|
||||
})
|
||||
})
|
||||
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.has('include_public')).toBe(false)
|
||||
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
it('treats an already-running scan (409) as success', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({ status: 'already_running' }, { ok: false, status: 409 })
|
||||
)
|
||||
|
||||
await expect(assetService.seedModelAssets()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws on other error statuses', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({}, { ok: false, status: 500 })
|
||||
)
|
||||
|
||||
await expect(assetService.seedModelAssets()).rejects.toThrow('500')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
import {
|
||||
assetFilenameSchema,
|
||||
assetItemSchema,
|
||||
assetResponseSchema,
|
||||
asyncUploadResponseSchema,
|
||||
@@ -17,9 +19,9 @@ import type {
|
||||
AssetUpdatePayload,
|
||||
AsyncUploadResponse,
|
||||
ModelFile,
|
||||
ModelFolder,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -180,6 +182,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
|
||||
}
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const ASSETS_SEED_ENDPOINT = '/assets/seed'
|
||||
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
|
||||
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
@@ -187,6 +190,8 @@ const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
/** Prefix for the namespaced tag that carries a model's folder category, e.g. `model_type:checkpoints`. */
|
||||
const MODEL_TYPE_TAG_PREFIX = 'model_type:'
|
||||
export const INPUT_TAG = 'input'
|
||||
export const OUTPUT_TAG = 'output'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
@@ -209,6 +214,48 @@ function normalizeAssetTags(tags: string[]): string[] {
|
||||
return tags.map((tag) => tag.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the model folder a tag represents, or undefined when the tag is not
|
||||
* a folder category. `supports_model_type_tags` backends carry the category as
|
||||
* a namespaced `model_type:<folder>` tag; older backends mint bare tags, which
|
||||
* may carry subfolder paths (e.g. `Chatterbox/sub/model`) and group by their
|
||||
* top-level segment, matching the asset browser's legacy grouping.
|
||||
*/
|
||||
function modelFolderFromTag(
|
||||
tag: string,
|
||||
modelTypeMode: boolean
|
||||
): string | undefined {
|
||||
if (modelTypeMode) {
|
||||
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
|
||||
: undefined
|
||||
}
|
||||
if (tag === MODELS_TAG || tag.length === 0) return undefined
|
||||
return tag.split('/')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders loader paths as subdirectories before files at every level,
|
||||
* alphabetical within each group. The asset API returns models in storage
|
||||
* order, which would otherwise interleave root-level files with folder
|
||||
* contents in the sidebar tree.
|
||||
*/
|
||||
function compareLoaderPaths(a: string, b: string): number {
|
||||
const aSegments = a.split('/')
|
||||
const bSegments = b.split('/')
|
||||
const sharedDepth = Math.min(aSegments.length, bSegments.length)
|
||||
for (let i = 0; i < sharedDepth; i++) {
|
||||
const aIsFile = i === aSegments.length - 1
|
||||
const bIsFile = i === bSegments.length - 1
|
||||
if (aIsFile !== bIsFile) return aIsFile ? 1 : -1
|
||||
const order = aSegments[i].localeCompare(bSegments[i], undefined, {
|
||||
numeric: true
|
||||
})
|
||||
if (order !== 0) return order
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async function withCallerAbort<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
@@ -269,6 +316,26 @@ function createAssetService() {
|
||||
let inputAssetsIncludingPublicRequestId = 0
|
||||
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
|
||||
|
||||
/**
|
||||
* Model assets bucketed by folder category, built from a single walk of the
|
||||
* `models` tag rather than a fetch per category. Shared by the folder list
|
||||
* and per-folder listings so the sidebar loads every model in one pass.
|
||||
*/
|
||||
let modelBuckets: Map<string, AssetItem[]> | null = null
|
||||
let modelBucketsRequestId = 0
|
||||
let pendingModelBuckets: Promise<Map<string, AssetItem[]>> | null = null
|
||||
|
||||
/**
|
||||
* Discards the cached model buckets so the next read re-walks the models
|
||||
* tag. Bumping the request id keeps a walk that was already in flight from
|
||||
* repopulating the cache with pre-invalidation data.
|
||||
*/
|
||||
function invalidateModelBuckets(): void {
|
||||
modelBucketsRequestId++
|
||||
modelBuckets = null
|
||||
pendingModelBuckets = null
|
||||
}
|
||||
|
||||
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
|
||||
function invalidateInputAssetsIncludingPublic(): void {
|
||||
inputAssetsIncludingPublicRequestId++
|
||||
@@ -330,51 +397,156 @@ function createAssetService() {
|
||||
return validateAssetResponse(data)
|
||||
}
|
||||
/**
|
||||
* Gets a list of model folder keys from the asset API
|
||||
*
|
||||
* Logic:
|
||||
* 1. Extract directory names directly from asset tags
|
||||
* 2. Filter out blacklisted directories
|
||||
* 3. Return alphabetically sorted directories with assets
|
||||
*
|
||||
* @returns The list of model folder keys
|
||||
* Walks every `models`-tagged asset once and buckets each into the folder
|
||||
* categories carried by its `model_type:` tags. A single asset lands in every
|
||||
* category it is tagged with (e.g. a shared-root model in both `checkpoints`
|
||||
* and `diffusion_models`). Which folders are actually shown is decided by
|
||||
* `/experiment/models`; models with no category tag are dropped with a warning
|
||||
* rather than hidden silently.
|
||||
*/
|
||||
async function getAssetModelFolders(): Promise<ModelFolder[]> {
|
||||
const data = await handleAssetRequest(
|
||||
{ includeTags: [MODELS_TAG] },
|
||||
'model folders'
|
||||
)
|
||||
async function buildModelBuckets(): Promise<Map<string, AssetItem[]>> {
|
||||
const assets = await getAllAssetsByTag(MODELS_TAG, true)
|
||||
const modelTypeMode = useFeatureFlags().flags.supportsModelTypeTags
|
||||
const buckets = new Map<string, AssetItem[]>()
|
||||
|
||||
// Blacklist directories we don't want to show
|
||||
const blacklistedDirectories = new Set(['configs'])
|
||||
for (const asset of assets) {
|
||||
const folders = asset.tags
|
||||
.map((tag) => modelFolderFromTag(tag, modelTypeMode))
|
||||
.filter((folder): folder is string => folder !== undefined)
|
||||
|
||||
const folderTags = data.assets
|
||||
.flatMap((asset) => asset.tags)
|
||||
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
|
||||
const discoveredFolders = new Set<string>(folderTags)
|
||||
if (folders.length === 0) {
|
||||
console.warn(
|
||||
`Asset ${asset.id} (${asset.name}) is tagged '${MODELS_TAG}' but has no model category; skipping.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Return only discovered folders in alphabetical order
|
||||
const sortedFolders = Array.from(discoveredFolders).toSorted()
|
||||
return sortedFolders.map((name) => ({ name, folders: [] }))
|
||||
// On loader_path-contract backends a null loader_path marks an
|
||||
// unloadable asset (e.g. an orphan): it must not mint a widget value,
|
||||
// and `name` is deprecated for path semantics.
|
||||
if (modelTypeMode && !asset.loader_path) {
|
||||
console.warn(
|
||||
`Asset ${asset.id} (${asset.name}) has no loader_path; skipping.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// The loader value flows into viewMetadata URLs and widget values, so a
|
||||
// traversal-shaped path must not pass through even if the backend's own
|
||||
// validation ever regresses.
|
||||
const loaderValue = asset.loader_path ?? getAssetFilename(asset)
|
||||
if (!assetFilenameSchema.safeParse(loaderValue).success) {
|
||||
console.warn(
|
||||
`Asset ${asset.id} (${asset.name}) has an unsafe loader path ('${loaderValue}'); skipping.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const folder of folders) {
|
||||
const bucket = buckets.get(folder)
|
||||
if (bucket) bucket.push(asset)
|
||||
else buckets.set(folder, [asset])
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of buckets.values()) {
|
||||
bucket.sort((a, b) =>
|
||||
compareLoaderPaths(
|
||||
a.loader_path ?? getAssetFilename(a),
|
||||
b.loader_path ?? getAssetFilename(b)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
/** Returns the memoized model buckets, walking the models tag on first read. */
|
||||
async function loadModelBuckets(): Promise<Map<string, AssetItem[]>> {
|
||||
if (modelBuckets) return modelBuckets
|
||||
if (pendingModelBuckets) return pendingModelBuckets
|
||||
|
||||
const requestId = ++modelBucketsRequestId
|
||||
const walk = async () => {
|
||||
try {
|
||||
const buckets = await buildModelBuckets()
|
||||
if (requestId === modelBucketsRequestId) {
|
||||
modelBuckets = buckets
|
||||
}
|
||||
return buckets
|
||||
} finally {
|
||||
if (requestId === modelBucketsRequestId) {
|
||||
pendingModelBuckets = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingModelBuckets = walk()
|
||||
return pendingModelBuckets
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of models in the specified folder from the asset API
|
||||
* Gets the models in the specified folder from the single models walk.
|
||||
* @param folder The folder to list models from, such as 'checkpoints'
|
||||
* @returns The list of model filenames within the specified folder
|
||||
*/
|
||||
async function getAssetModels(folder: string): Promise<ModelFile[]> {
|
||||
const data = await handleAssetRequest(
|
||||
{ includeTags: [MODELS_TAG, folder] },
|
||||
`models for ${folder}`
|
||||
)
|
||||
|
||||
return data.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
const buckets = await loadModelBuckets()
|
||||
return (buckets.get(folder) ?? []).map((asset) => ({
|
||||
// `loader_path` is the category-relative path the loader widget expects
|
||||
// and the source for the sidebar tree. Backends that predate it (bare-tag
|
||||
// mode; today's cloud) fall back to the filename metadata — the same
|
||||
// value the asset browser serializes — rather than `name`, which is a
|
||||
// content hash on cloud.
|
||||
name: asset.loader_path ?? getAssetFilename(asset),
|
||||
// Asset records carry no root identity, so every model reports root 0.
|
||||
// Known limitation on multi-root categories (extra_model_paths.yaml):
|
||||
// preview reads target root 0 (wrong file or 404 for secondary-root
|
||||
// files), and same-relative-path files in different roots collapse
|
||||
// onto one sidebar row. Metadata is unaffected unless relative paths
|
||||
// collide (/view_metadata searches roots in order without an index),
|
||||
// as are loader widget values; lifting this needs the backend to carry
|
||||
// root identity on assets.
|
||||
pathIndex: 0
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the backend to rescan the model roots on disk so newly added files
|
||||
* become assets. Fire-and-forget: the scan's fast (insert) phase already
|
||||
* writes the category tags and filenames the sidebar needs and is announced
|
||||
* by an `assets.seed.fast_complete` websocket event. A 409 means a scan is
|
||||
* already running, which will emit the same event, so it is not an error.
|
||||
*/
|
||||
async function seedModelAssets(): Promise<void> {
|
||||
const res = await api.fetchApi(ASSETS_SEED_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roots: ['models'] })
|
||||
})
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(
|
||||
`Unable to start asset scan: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the backend's scan fast-phase completion broadcast — the
|
||||
* moment newly scanned files' tags and loader paths become queryable. The
|
||||
* wire-level event (`assets.seed.fast_complete`) is owned here; consumers
|
||||
* receive a callback and an unsubscribe function.
|
||||
*/
|
||||
function onModelsScanned(callback: () => void | Promise<void>): () => void {
|
||||
const handler = () => {
|
||||
void callback()
|
||||
}
|
||||
api.addCustomEventListener('assets.seed.fast_complete', handler)
|
||||
return () => {
|
||||
api.removeCustomEventListener('assets.seed.fast_complete', handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
|
||||
*
|
||||
@@ -971,8 +1143,10 @@ function createAssetService() {
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
invalidateModelBuckets,
|
||||
onModelsScanned,
|
||||
seedModelAssets,
|
||||
isAssetAPIEnabled,
|
||||
isAssetBrowserEligible,
|
||||
shouldUseAssetBrowser,
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('assetFilterUtils properties', () => {
|
||||
it('filterByCategory("all") accepts every asset', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetItem, (asset) => {
|
||||
expect(filterByCategory('all')(asset)).toBe(true)
|
||||
expect(filterByCategory('all', false)(asset)).toBe(true)
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -56,7 +56,7 @@ describe('assetFilterUtils properties', () => {
|
||||
fc.array(arbAssetItem, { maxLength: 30 }),
|
||||
fc.stringMatching(/^[a-z]{1,8}$/),
|
||||
(assets, category) => {
|
||||
const filter = filterByCategory(category)
|
||||
const filter = filterByCategory(category, false)
|
||||
const result = assets.filter(filter)
|
||||
expect(result.length).toBeLessThanOrEqual(assets.length)
|
||||
for (const item of result) {
|
||||
|
||||
@@ -26,19 +26,54 @@ function createAsset(
|
||||
|
||||
describe('filterByCategory', () => {
|
||||
it.for([
|
||||
{ category: 'all', tags: ['checkpoint'], expected: true },
|
||||
{ category: 'checkpoint', tags: ['checkpoint'], expected: true },
|
||||
{ category: 'lora', tags: ['checkpoint'], expected: false },
|
||||
{ category: 'all', tags: ['checkpoint'], mode: false, expected: true },
|
||||
{
|
||||
category: 'checkpoint',
|
||||
tags: ['checkpoint'],
|
||||
mode: false,
|
||||
expected: true
|
||||
},
|
||||
{ category: 'lora', tags: ['checkpoint'], mode: false, expected: false },
|
||||
{
|
||||
category: 'checkpoint',
|
||||
tags: ['models', 'checkpoint/xl'],
|
||||
mode: false,
|
||||
expected: true
|
||||
},
|
||||
{ category: 'xl', tags: ['models', 'checkpoint/xl'], expected: false }
|
||||
{
|
||||
category: 'xl',
|
||||
tags: ['models', 'checkpoint/xl'],
|
||||
mode: false,
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
category: 'checkpoints',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
mode: true,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
category: 'LLM',
|
||||
tags: ['models', 'model_type:LLM'],
|
||||
mode: true,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
category: 'sdxl',
|
||||
tags: ['models', 'model_type:checkpoints', 'sdxl'],
|
||||
mode: true,
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
category: 'checkpoints',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
mode: false,
|
||||
expected: false
|
||||
}
|
||||
])(
|
||||
'category=$category with tags=$tags returns $expected',
|
||||
({ category, tags, expected }) => {
|
||||
const filter = filterByCategory(category)
|
||||
'category=$category tags=$tags mode=$mode returns $expected',
|
||||
({ category, tags, mode, expected }) => {
|
||||
const filter = filterByCategory(category, mode)
|
||||
const asset = createAsset('model.safetensors', { tags })
|
||||
expect(filter(asset)).toBe(expected)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
|
||||
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetCategories
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
export function filterByCategory(category: string) {
|
||||
export function filterByCategory(category: string, modelTypeMode: boolean) {
|
||||
return (asset: AssetItem) => {
|
||||
if (category === 'all') return true
|
||||
|
||||
// Check if any tag matches the category (for exact matches)
|
||||
if (asset.tags.includes(category)) return true
|
||||
|
||||
// Check if any tag's top-level folder matches the category
|
||||
return asset.tags.some((tag) => {
|
||||
if (typeof tag === 'string' && tag.includes('/')) {
|
||||
return tag.split('/')[0] === category
|
||||
}
|
||||
return false
|
||||
})
|
||||
return getAssetCategories(asset, modelTypeMode).includes(category)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,34 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
MISSING_TAG,
|
||||
MODELS_TAG
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
buildModelTypeTagUpdate,
|
||||
getAssetAdditionalTags,
|
||||
getAssetBaseModel,
|
||||
getAssetBaseModels,
|
||||
getAssetCardTitle,
|
||||
getAssetCategories,
|
||||
getAssetDescription,
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetNodeCategoryCandidates,
|
||||
getAssetSourceUrl,
|
||||
getPrimaryCategoryTag,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetTypeBadges,
|
||||
getAssetUserDescription,
|
||||
getEditableModelType,
|
||||
getSourceName,
|
||||
resolveDisplayImageDimensions
|
||||
resolveDisplayImageDimensions,
|
||||
stripModelTypePrefix,
|
||||
toModelTypeTag
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
@@ -274,7 +286,17 @@ describe('assetMetadataUtils', () => {
|
||||
tags: ['models'],
|
||||
expected: null
|
||||
},
|
||||
{ name: 'returns null when tags empty', tags: [], expected: null }
|
||||
{ name: 'returns null when tags empty', tags: [], expected: null },
|
||||
{
|
||||
name: 'never returns a raw model_type: literal (no round-trip into edit widgets)',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
name: 'skips model_type: tags in favour of the bare twin',
|
||||
tags: ['models', 'model_type:checkpoints', 'checkpoints'],
|
||||
expected: 'checkpoints'
|
||||
}
|
||||
])('$name', ({ tags, expected }) => {
|
||||
const asset = { ...mockAsset, tags }
|
||||
expect(getAssetModelType(asset)).toBe(expected)
|
||||
@@ -540,3 +562,353 @@ describe('assetMetadataUtils', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetCategories', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('uses model_type:* values as the group and disregards other tags in model_type mode', () => {
|
||||
expect(
|
||||
getAssetCategories(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints'])
|
||||
})
|
||||
|
||||
it('preserves the model_type value casing', () => {
|
||||
expect(
|
||||
getAssetCategories(asset(['models', 'model_type:LLM']), true)
|
||||
).toEqual(['LLM'])
|
||||
})
|
||||
|
||||
it('routes an uncovered asset by its bare tags in model_type mode', () => {
|
||||
expect(getAssetCategories(asset(['models', 'checkpoints']), true)).toEqual([
|
||||
'checkpoints'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores model_type: and uses bare-tag grouping when mode is off', () => {
|
||||
expect(
|
||||
getAssetCategories(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
false
|
||||
)
|
||||
).toEqual(['model_type:checkpoints', 'sdxl'])
|
||||
})
|
||||
|
||||
it('never surfaces namespace residue as a category for uncovered assets in mode', () => {
|
||||
expect(
|
||||
getAssetCategories(asset(['models', 'model_type:', 'sdxl']), true)
|
||||
).toEqual(['sdxl'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPrimaryCategoryTag', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('uses the model_type value a covered asset groups under', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'sdxl', 'model_type:vae']), true)
|
||||
).toBe('vae')
|
||||
})
|
||||
|
||||
it('keeps the legacy verbatim tag for an uncovered hierarchical asset', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'Chatterbox/sub/model']), true)
|
||||
).toBe('Chatterbox/sub/model')
|
||||
})
|
||||
|
||||
it('skips namespace residue instead of titling off a raw model_type: tag', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'model_type:']), true)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the legacy first non-models tag when mode is off', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'model_type:vae']), false)
|
||||
).toBe('model_type:vae')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetNodeCategoryCandidates', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('orders the most specific (deepest) tag ahead of a flat model_type value', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']),
|
||||
true
|
||||
)
|
||||
).toEqual(['LLM/Qwen-VL/Qwen3-0.6B', 'LLM'])
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix when it is the only candidate', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), true)
|
||||
).toEqual(['vae'])
|
||||
})
|
||||
|
||||
it('keeps a model_type value ahead of an equally-deep bare tag', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints', 'sdxl'])
|
||||
})
|
||||
|
||||
it('demotes an unrelated deeper bare tag below the model_type value', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:vae', 'foo/bar']),
|
||||
true
|
||||
)
|
||||
).toEqual(['vae', 'foo/bar'])
|
||||
})
|
||||
|
||||
it('keeps unrelated bare tags as trailing fallbacks rather than dropping them', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL', 'foo/bar/baz']),
|
||||
true
|
||||
)
|
||||
).toEqual(['LLM/Qwen-VL', 'LLM', 'foo/bar/baz'])
|
||||
})
|
||||
|
||||
it('keeps a hierarchical tag intact', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'chatterbox/chatterbox_vc']),
|
||||
true
|
||||
)
|
||||
).toEqual(['chatterbox/chatterbox_vc'])
|
||||
})
|
||||
|
||||
it('returns no candidates when only reserved tags are present', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'missing']), true)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the first non-reserved tag verbatim when mode is off', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), false)
|
||||
).toEqual(['model_type:vae'])
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'checkpoints']), false)
|
||||
).toEqual(['checkpoints'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetTypeBadges', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix in model_type mode (no raw leak)', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints'])
|
||||
})
|
||||
|
||||
it('badges the model_type value even when a bare tag comes first, matching the grouping', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(asset(['models', 'foo', 'model_type:bar']), true)
|
||||
).toEqual(['bar'])
|
||||
})
|
||||
|
||||
it('badges every category a shared multi-type asset groups under', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(
|
||||
asset([
|
||||
'models',
|
||||
'model_type:checkpoints',
|
||||
'model_type:diffusion_models'
|
||||
]),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints', 'diffusion_models'])
|
||||
})
|
||||
|
||||
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
|
||||
expect(getAssetTypeBadges(asset(['models', 'sdxl']), true)).toEqual([
|
||||
'sdxl'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no badge rather than a blank one for a malformed empty model_type: tag', () => {
|
||||
expect(getAssetTypeBadges(asset(['models', 'model_type:']), true)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('leaks the literal model_type: tag when mode is off', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(asset(['models', 'model_type:checkpoints']), false)
|
||||
).toEqual(['model_type:checkpoints'])
|
||||
})
|
||||
|
||||
it('shows the segment after the first slash for a bare hierarchical tag', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(asset(['models', 'checkpoint/xl']), false)
|
||||
).toEqual(['xl'])
|
||||
})
|
||||
|
||||
it('returns no badges when only the models tag is present', () => {
|
||||
expect(getAssetTypeBadges(asset(['models']), true)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripModelTypePrefix', () => {
|
||||
it('removes the model_type: prefix when present', () => {
|
||||
expect(stripModelTypePrefix('model_type:checkpoints')).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('leaves a tag without the prefix unchanged', () => {
|
||||
expect(stripModelTypePrefix('checkpoints')).toBe('checkpoints')
|
||||
expect(stripModelTypePrefix('checkpoint/xl')).toBe('checkpoint/xl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toModelTypeTag', () => {
|
||||
it('prefixes a folder_name with the model_type namespace', () => {
|
||||
expect(toModelTypeTag('checkpoints')).toBe('model_type:checkpoints')
|
||||
expect(toModelTypeTag('ultralytics_bbox')).toBe(
|
||||
'model_type:ultralytics_bbox'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEditableModelType', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('returns the stripped model_type value in model_type mode', () => {
|
||||
expect(
|
||||
getEditableModelType(
|
||||
asset(['models', 'checkpoints', 'model_type:checkpoints']),
|
||||
true
|
||||
)
|
||||
).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
|
||||
expect(getEditableModelType(asset(['models', 'sam2']), true)).toBe('sam2')
|
||||
})
|
||||
|
||||
it('uses the legacy first-non-models tag when mode is off', () => {
|
||||
expect(
|
||||
getEditableModelType(asset(['models', 'checkpoints', 'sdxl']), false)
|
||||
).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('returns null when only the models tag is present', () => {
|
||||
expect(getEditableModelType(asset(['models']), true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildModelTypeTagUpdate', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('swaps the bare subtype tag when mode is off', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(asset(['models', 'checkpoints']), 'loras', false)
|
||||
).toEqual(['models', 'loras'])
|
||||
})
|
||||
|
||||
it('preserves user labels and swaps only the subtype tag when mode is off', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset(['models', 'checkpoints', 'sdxl']),
|
||||
'loras',
|
||||
false
|
||||
)
|
||||
).toEqual(['models', 'sdxl', 'loras'])
|
||||
})
|
||||
|
||||
it('writes only the model_type form for a covered asset, leaving the bare twin for the backend', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset(['models', 'checkpoints', 'model_type:checkpoints']),
|
||||
'loras',
|
||||
true
|
||||
)
|
||||
).toEqual(['models', 'checkpoints', 'model_type:loras'])
|
||||
})
|
||||
|
||||
it('replaces every existing model_type form for a shared-path dual-tagged asset', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset([
|
||||
'models',
|
||||
'diffusion_models',
|
||||
'model_type:diffusion_models',
|
||||
'model_type:unet_gguf'
|
||||
]),
|
||||
'loras',
|
||||
true
|
||||
)
|
||||
).toEqual(['models', 'diffusion_models', 'model_type:loras'])
|
||||
})
|
||||
|
||||
it('drops the bare current type for an uncovered asset in model_type mode', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(asset(['models', 'sam2']), 'loras', true)
|
||||
).toEqual(['models', 'model_type:loras'])
|
||||
})
|
||||
|
||||
it('keeps user labels untouched in model_type mode', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset(['models', 'checkpoints', 'model_type:checkpoints', 'sdxl']),
|
||||
'loras',
|
||||
true
|
||||
)
|
||||
).toEqual(['models', 'checkpoints', 'sdxl', 'model_type:loras'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserved tag mirrors', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it("treats assetService's canonical reserved tags as reserved (locals must not drift)", () => {
|
||||
expect(getAssetCategories(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset([MODELS_TAG, MISSING_TAG, 'x']),
|
||||
true
|
||||
)
|
||||
).toEqual(['x'])
|
||||
expect(getAssetTypeBadges(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
|
||||
expect(getAssetModelType(asset([MODELS_TAG]))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isCivitaiUrl } from '@/utils/formatUtil'
|
||||
|
||||
// Reserved tag literals (mirror assetService's MODELS_TAG/MISSING_TAG). Kept
|
||||
// local so this leaf util doesn't pull the heavier assetService -> i18n chain.
|
||||
const MODELS_TAG = 'models'
|
||||
const MISSING_TAG = 'missing'
|
||||
|
||||
/**
|
||||
* Type-safe utilities for extracting metadata from assets.
|
||||
* These utilities check user_metadata first, then metadata, then fallback.
|
||||
@@ -140,16 +145,236 @@ export function getSourceName(url: string): string {
|
||||
return 'Source'
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_TAG_PREFIX = 'model_type:'
|
||||
|
||||
/**
|
||||
* Extracts the model type from asset tags
|
||||
* Extracts the model type from asset tags as a bare (non-namespaced) value.
|
||||
* Never returns a raw `model_type:*` literal: this value feeds edit widgets
|
||||
* whose save path writes tags back verbatim, so a namespaced tag leaking
|
||||
* through here would round-trip the prefixed literal into the tag set.
|
||||
* @param asset - The asset to extract model type from
|
||||
* @returns The model type string or null if not present
|
||||
*/
|
||||
export function getAssetModelType(asset: AssetItem): string | null {
|
||||
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
|
||||
const typeTag = asset.tags?.find(
|
||||
(tag) => tag && tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
return typeTag ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the namespaced subtype tag the backend stores in `model_type:` mode.
|
||||
* The argument is a discovery folder_name (e.g. `checkpoints`,
|
||||
* `ultralytics_bbox`); the backend keeps the bare directory-path twin in sync.
|
||||
*/
|
||||
export function toModelTypeTag(folderName: string): string {
|
||||
return `${MODEL_TYPE_TAG_PREFIX}${folderName}`
|
||||
}
|
||||
|
||||
/** Strips the `model_type:` prefix off each namespaced tag, dropping non-`model_type:` tags. */
|
||||
function getModelTypeTagValues(asset: AssetItem): string[] {
|
||||
return asset.tags
|
||||
.filter((tag) => tag.startsWith(MODEL_TYPE_TAG_PREFIX))
|
||||
.map((tag) => tag.slice(MODEL_TYPE_TAG_PREFIX.length))
|
||||
.filter((tag) => tag.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the folder_name shown as the asset's current model type in the edit
|
||||
* dropdown. In `modelTypeMode` the stripped `model_type:` value is authoritative
|
||||
* (covered assets); an uncovered asset with no `model_type:` tag falls back to
|
||||
* its bare subtype tag, mirroring the read-side grouping. Outside the mode this
|
||||
* is the legacy first-non-`models` tag.
|
||||
*/
|
||||
export function getEditableModelType(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string | null {
|
||||
if (modelTypeMode) {
|
||||
const [modelType] = getModelTypeTagValues(asset)
|
||||
if (modelType) return modelType
|
||||
}
|
||||
return getAssetModelType(asset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the tag set for re-typing a model asset to `newFolderName`. In
|
||||
* `modelTypeMode` only the `model_type:` form is written — the backend keeps the
|
||||
* bare directory-path twin in sync, so existing `model_type:` tags are dropped
|
||||
* (covered assets) or the bare current type is dropped (uncovered assets) and
|
||||
* the new `model_type:<folder_name>` is added. Outside the mode it swaps the
|
||||
* legacy bare subtype tag, preserving the pre-namespace behavior.
|
||||
*/
|
||||
export function buildModelTypeTagUpdate(
|
||||
asset: AssetItem,
|
||||
newFolderName: string,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (!modelTypeMode) {
|
||||
const currentType = getAssetModelType(asset)
|
||||
return asset.tags.filter((tag) => tag !== currentType).concat(newFolderName)
|
||||
}
|
||||
|
||||
const modelTypeTags = asset.tags.filter((tag) =>
|
||||
tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
const currentBareType = getAssetModelType(asset)
|
||||
const tagsToRemove =
|
||||
modelTypeTags.length > 0
|
||||
? new Set(modelTypeTags)
|
||||
: new Set(currentBareType ? [currentBareType] : [])
|
||||
|
||||
return asset.tags
|
||||
.filter((tag) => !tagsToRemove.has(tag))
|
||||
.concat(toModelTypeTag(newFolderName))
|
||||
}
|
||||
|
||||
/** Legacy grouping: each non-`models` tag's top-level path segment. */
|
||||
function getBareTagCategories(asset: AssetItem): string[] {
|
||||
return asset.tags
|
||||
.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
|
||||
.map((tag) => tag.split('/')[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the category keys a model asset is grouped under.
|
||||
*
|
||||
* `modelTypeMode` reflects whether the backend declares the `model_type:` tag
|
||||
* scheme (the `supports_model_type_tags` capability). When true, an asset's
|
||||
* `model_type:*` values are authoritative; an asset with no `model_type:` tag
|
||||
* still routes by its bare tags. When false (the default) categories come from
|
||||
* the legacy bare-tag top-level grouping and `model_type:` is ignored.
|
||||
*/
|
||||
export function getAssetCategories(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (modelTypeMode) {
|
||||
const modelTypes = getModelTypeTagValues(asset)
|
||||
if (modelTypes.length > 0) return modelTypes
|
||||
// Uncovered assets route by bare tags, but namespace residue (e.g. a
|
||||
// malformed empty `model_type:`) must not surface as a raw category.
|
||||
return getBareTagCategories(asset).filter(
|
||||
(category) => !category.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
}
|
||||
|
||||
return getBareTagCategories(asset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the primary tag a browser surface titles itself after. In
|
||||
* `modelTypeMode` a covered asset uses its first `model_type:*` value — the
|
||||
* key it groups under — while an uncovered asset keeps the legacy selection
|
||||
* (first non-`models` tag, verbatim, hierarchical paths intact). Outside the
|
||||
* mode this is exactly the legacy selection.
|
||||
*/
|
||||
export function getPrimaryCategoryTag(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string | undefined {
|
||||
if (modelTypeMode) {
|
||||
const [modelType] = getModelTypeTagValues(asset)
|
||||
if (modelType) return modelType
|
||||
return asset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
}
|
||||
return asset.tags.find((tag) => tag !== MODELS_TAG)
|
||||
}
|
||||
|
||||
/** Number of `parent/child` segments in a tag, used to pick the most specific. */
|
||||
function pathDepth(tag: string): number {
|
||||
return tag.split('/').length
|
||||
}
|
||||
|
||||
/** Removes the `model_type:` namespace prefix from a tag when present. */
|
||||
export function stripModelTypePrefix(tag: string): string {
|
||||
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
|
||||
: tag
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the labels shown as an asset card's type badges.
|
||||
*
|
||||
* In `modelTypeMode` a covered asset badges every `model_type:*` value — the
|
||||
* same keys it groups under (`getAssetCategories`) — so a shared-root asset
|
||||
* tagged with several categories carries each of them; whichever category
|
||||
* view the card appears in is represented on the card. Uncovered assets (and
|
||||
* legacy mode) keep the original single selection: first non-`models` tag,
|
||||
* with bare hierarchical tags showing the segment after the first `/`.
|
||||
*/
|
||||
export function getAssetTypeBadges(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (modelTypeMode) {
|
||||
const modelTypes = getModelTypeTagValues(asset)
|
||||
if (modelTypes.length > 0) return modelTypes
|
||||
}
|
||||
const typeTag = asset.tags.find(
|
||||
(tag) =>
|
||||
tag !== MODELS_TAG &&
|
||||
!(modelTypeMode && tag.startsWith(MODEL_TYPE_TAG_PREFIX))
|
||||
)
|
||||
if (!typeTag) return []
|
||||
return [
|
||||
typeTag.includes('/') ? typeTag.slice(typeTag.indexOf('/') + 1) : typeTag
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered node-category candidates for an asset, most specific first.
|
||||
*
|
||||
* Callers resolve a node provider by trying each candidate in order and taking
|
||||
* the first that maps to a provider. The full (possibly hierarchical) value is
|
||||
* kept so `modelToNodeStore`'s `parent/child` fallback still works.
|
||||
*
|
||||
* In `modelTypeMode` (backend declares `supports_model_type_tags`) candidates
|
||||
* come in two tiers. Tier 1: the stripped `model_type:*` values plus bare tags
|
||||
* *related* to one of them (equal to it, or extending it as a `parent/child`
|
||||
* path), ordered by descending depth — so a resolvable `LLM/Qwen-VL/...` twin
|
||||
* wins over a flat `model_type:LLM`, while ties keep `model_type:*` values
|
||||
* ahead of bare tags. Tier 2: unrelated bare tags (e.g. a user-added
|
||||
* `foo/bar`), tried only when nothing authoritative resolves — they can no
|
||||
* longer pre-empt a resolvable `model_type:*` value however deep they are.
|
||||
* An uncovered asset (no `model_type:` tag) routes by all its bare tags,
|
||||
* deepest first. Outside `modelTypeMode` the legacy first-non-reserved tag is
|
||||
* used verbatim.
|
||||
*/
|
||||
export function getAssetNodeCategoryCandidates(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (!modelTypeMode) {
|
||||
const legacy = asset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
|
||||
)
|
||||
return legacy ? [legacy] : []
|
||||
}
|
||||
|
||||
const bareTags = asset.tags.filter(
|
||||
(tag) =>
|
||||
tag !== MODELS_TAG &&
|
||||
tag !== MISSING_TAG &&
|
||||
!tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
|
||||
const byDepthDesc = (a: string, b: string) => pathDepth(b) - pathDepth(a)
|
||||
|
||||
const modelTypes = getModelTypeTagValues(asset)
|
||||
if (modelTypes.length === 0) return bareTags.toSorted(byDepthDesc)
|
||||
|
||||
const isRelated = (tag: string) =>
|
||||
modelTypes.some((type) => tag === type || tag.startsWith(`${type}/`))
|
||||
|
||||
return [
|
||||
...[...modelTypes, ...bareTags.filter(isRelated)].sort(byDepthDesc),
|
||||
...bareTags.filter((tag) => !isRelated(tag)).sort(byDepthDesc)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts user description from asset user_metadata
|
||||
* @param asset - The asset to extract user description from
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
|
||||
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
@@ -49,6 +60,11 @@ describe('resolveModelNodeFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockSupportsModelTypeTags.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('valid assets', () => {
|
||||
@@ -68,6 +84,48 @@ describe('resolveModelNodeFromAsset', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix when resolving the provider in model_type mode', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
mockProvider(createMockNodeProvider())
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({ tags: ['models', 'model_type:vae'] })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
|
||||
})
|
||||
|
||||
it('skips an unresolvable incidental tag and resolves via the model_type value', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
mockGetNodeProvider.mockImplementation((category: string) =>
|
||||
category === 'vae' ? createMockNodeProvider() : undefined
|
||||
)
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({ tags: ['models', 'model_type:vae', 'foo/bar'] })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('foo/bar')
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
|
||||
})
|
||||
|
||||
it('prefers the deepest resolvable path over a flat model_type value', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
mockGetNodeProvider.mockImplementation((category: string) =>
|
||||
category === 'LLM/Qwen-VL/Qwen3-0.6B'
|
||||
? createMockNodeProvider()
|
||||
: undefined
|
||||
)
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({
|
||||
tags: ['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('LLM/Qwen-VL/Qwen3-0.6B')
|
||||
})
|
||||
|
||||
it('falls back to metadata.filename when user_metadata.filename missing', () => {
|
||||
mockProvider(createMockNodeProvider())
|
||||
const result = resolveModelNodeFromAsset(
|
||||
@@ -201,7 +259,7 @@ describe('resolveModelNodeFromAsset', () => {
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NO_PROVIDER')
|
||||
expect(result.error.message).toContain('checkpoints')
|
||||
expect(result.error.details?.category).toBe('checkpoints')
|
||||
expect(result.error.details?.candidates).toEqual(['checkpoints'])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
MISSING_TAG,
|
||||
MODELS_TAG
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetFilename,
|
||||
getAssetNodeCategoryCandidates
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -81,10 +85,12 @@ export function resolveModelNodeFromAsset(
|
||||
}
|
||||
}
|
||||
|
||||
const category = validAsset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
|
||||
const { flags } = useFeatureFlags()
|
||||
const candidates = getAssetNodeCategoryCandidates(
|
||||
validAsset,
|
||||
flags.supportsModelTypeTags
|
||||
)
|
||||
if (!category) {
|
||||
if (candidates.length === 0) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
|
||||
)
|
||||
@@ -99,19 +105,31 @@ export function resolveModelNodeFromAsset(
|
||||
}
|
||||
}
|
||||
|
||||
const provider = useModelToNodeStore().getNodeProvider(category)
|
||||
if (!provider) {
|
||||
console.error(`No node provider registered for category: ${category}`)
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const resolved = candidates
|
||||
.map((category) => ({
|
||||
category,
|
||||
provider: modelToNodeStore.getNodeProvider(category)
|
||||
}))
|
||||
.find((candidate) => candidate.provider !== undefined)
|
||||
|
||||
if (!resolved?.provider) {
|
||||
// Known gap (out of scope for FE-1076): flat `model_type:LLM`-style tags
|
||||
// whose loaders are only registered hierarchically land here until the
|
||||
// backend emits a subtype-carrying tag.
|
||||
console.error(
|
||||
`No node provider registered for category: ${candidates.join(', ')}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NO_PROVIDER',
|
||||
message: `No node provider registered for category: ${category}`,
|
||||
message: `No node provider registered for category: ${candidates.join(', ')}`,
|
||||
assetId: validAsset.id,
|
||||
details: { category }
|
||||
details: { candidates }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, value: { provider, filename } }
|
||||
return { success: true, value: { provider: resolved.provider, filename } }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col">
|
||||
<div
|
||||
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
|
||||
>
|
||||
<h1
|
||||
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
{{ $t('cloudOnboarding.survey.title') }}
|
||||
</h1>
|
||||
<DynamicSurveyForm
|
||||
:key="activeSurvey.version"
|
||||
:survey="activeSurvey"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import CloudTemplate from './CloudTemplate.vue'
|
||||
|
||||
const renderWithMeta = async (meta: Record<string, unknown>) => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', name: 'test', component: CloudTemplate, meta }]
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
return render(CloudTemplate, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: {
|
||||
CloudHeroCarousel: { template: '<div data-testid="hero" />' },
|
||||
CloudTemplateFooter: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('CloudTemplate', () => {
|
||||
it('shows the hero carousel when the route does not hide it', async () => {
|
||||
await renderWithMeta({})
|
||||
expect(screen.getByTestId('hero')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the hero carousel when route.meta.hideHero is set', async () => {
|
||||
await renderWithMeta({ hideHero: true })
|
||||
expect(screen.queryByTestId('hero')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -13,15 +13,22 @@
|
||||
</div>
|
||||
<CloudTemplateFooter />
|
||||
</div>
|
||||
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
|
||||
<div
|
||||
v-if="!route.meta.hideHero"
|
||||
class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block"
|
||||
>
|
||||
<CloudHeroCarousel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
|
||||
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
<style>
|
||||
@import '../assets/css/fonts.css';
|
||||
|
||||
@@ -5,20 +5,22 @@
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-sm text-gray-600 no-underline"
|
||||
rel="noopener noreferrer"
|
||||
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
<a
|
||||
href="https://www.comfy.org/privacy-policy"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-sm text-gray-600 no-underline"
|
||||
rel="noopener noreferrer"
|
||||
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }}
|
||||
</a>
|
||||
<a
|
||||
href="https://support.comfy.org"
|
||||
class="cursor-pointer text-sm text-gray-600 no-underline"
|
||||
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -94,7 +94,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
name: 'cloud-survey',
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true, hideHero: true }
|
||||
},
|
||||
{
|
||||
path: 'oauth/consent',
|
||||
@@ -106,7 +106,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
name: 'cloud-user-check',
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/UserCheckView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true, hideHero: true }
|
||||
},
|
||||
{
|
||||
path: 'sorry-contact-support',
|
||||
|
||||
176
src/platform/cloud/onboarding/survey/DynamicSurveyField.test.ts
Normal file
176
src/platform/cloud/onboarding/survey/DynamicSurveyField.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { OnboardingSurveyField } from '@/platform/remoteConfig/types'
|
||||
|
||||
import DynamicSurveyField from './DynamicSurveyField.vue'
|
||||
|
||||
const renderField = (
|
||||
field: OnboardingSurveyField,
|
||||
props: {
|
||||
modelValue?: string | string[]
|
||||
otherValue?: string
|
||||
errorMessage?: string
|
||||
} = {},
|
||||
locale = 'en'
|
||||
) =>
|
||||
render(DynamicSurveyField, {
|
||||
global: {
|
||||
plugins: [
|
||||
createI18n({ legacy: false, locale, messages: { en: enMessages } })
|
||||
]
|
||||
},
|
||||
props: { field, modelValue: undefined, ...props }
|
||||
})
|
||||
|
||||
const optionButton = (label: string) =>
|
||||
screen.getByRole('button', { name: label })
|
||||
|
||||
describe('DynamicSurveyField', () => {
|
||||
const singleField: OnboardingSurveyField = {
|
||||
id: 'intent',
|
||||
type: 'single',
|
||||
label: 'What do you want to make?',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'images', label: 'Images', icon: 'icon-[lucide--image]' },
|
||||
{ value: 'video', label: 'Video' }
|
||||
]
|
||||
}
|
||||
|
||||
it('renders the label and one card per option', () => {
|
||||
renderField(singleField)
|
||||
expect(screen.getByText('What do you want to make?')).toBeVisible()
|
||||
expect(screen.getByText('Images')).toBeInTheDocument()
|
||||
expect(screen.getByText('Video')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits the chosen value for a single-select card', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderField(singleField)
|
||||
|
||||
await user.click(screen.getByText('Images'))
|
||||
expect(emitted()['update:modelValue']?.[0]).toEqual(['images'])
|
||||
})
|
||||
|
||||
it('marks the selected single card as on (aria-pressed/state)', () => {
|
||||
renderField(singleField, { modelValue: 'images' })
|
||||
expect(optionButton('Images')).toHaveAttribute('data-state', 'on')
|
||||
expect(optionButton('Video')).toHaveAttribute('data-state', 'off')
|
||||
})
|
||||
|
||||
it('gives each option card a stable "<fieldId>-<value>" id', () => {
|
||||
renderField(singleField)
|
||||
expect(optionButton('Images')).toHaveAttribute('id', 'intent-images')
|
||||
expect(optionButton('Video')).toHaveAttribute('id', 'intent-video')
|
||||
})
|
||||
|
||||
const multiField: OnboardingSurveyField = {
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
label: 'Pick some',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'a', label: 'Making A' },
|
||||
{ value: 'b', label: 'Making B' }
|
||||
]
|
||||
}
|
||||
|
||||
it('emits an array for a multi-select card and reflects current selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderField(multiField, { modelValue: ['a'] })
|
||||
|
||||
expect(optionButton('Making A')).toHaveAttribute('data-state', 'on')
|
||||
await user.click(screen.getByText('Making B'))
|
||||
const events = emitted()['update:modelValue'] as unknown[][] | undefined
|
||||
const last = events?.at(-1)?.[0]
|
||||
expect(last).toEqual(expect.arrayContaining(['a', 'b']))
|
||||
})
|
||||
|
||||
it('shows the "other" free-text input only when "other" is selected and emits it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const otherField: OnboardingSurveyField = {
|
||||
id: 'source',
|
||||
type: 'single',
|
||||
label: 'How did you find us?',
|
||||
required: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'sourceOther',
|
||||
options: [
|
||||
{ value: 'search', label: 'Web search' },
|
||||
{ value: 'other', label: 'Somewhere else' }
|
||||
]
|
||||
}
|
||||
|
||||
const { rerender, emitted } = renderField(otherField, {
|
||||
modelValue: 'search'
|
||||
})
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Where did you find us?')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ field: otherField, modelValue: 'other', otherValue: '' })
|
||||
const input = screen.getByPlaceholderText('Where did you find us?')
|
||||
await user.type(input, 'A podcast')
|
||||
expect(emitted()['update:otherValue']?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders a text field and emits typed input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const textField: OnboardingSurveyField = {
|
||||
id: 'note',
|
||||
type: 'text',
|
||||
label: 'Anything else?',
|
||||
placeholder: 'Your note'
|
||||
}
|
||||
const { emitted } = renderField(textField)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Your note'), 'Hi')
|
||||
expect(emitted()['update:modelValue']?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('resolves labels via labelKey, locale map, and falls back to the value', () => {
|
||||
renderField(
|
||||
{
|
||||
id: 'q',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_intent',
|
||||
options: [
|
||||
{ value: 'x', label: { en: 'Ex', ko: '엑스' } },
|
||||
{ value: 'raw' } // no label → falls back to the value
|
||||
]
|
||||
},
|
||||
{}
|
||||
)
|
||||
expect(screen.getByText('What do you want to make?')).toBeVisible()
|
||||
expect(screen.getByText('Ex')).toBeInTheDocument()
|
||||
expect(screen.getByText('raw')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves a field label from a locale map when no labelKey is set', () => {
|
||||
renderField({
|
||||
id: 'q',
|
||||
type: 'single',
|
||||
label: { en: 'Server question', ko: '서버 질문' },
|
||||
options: [{ value: 'a', label: 'A' }]
|
||||
})
|
||||
expect(screen.getByText('Server question')).toBeVisible()
|
||||
})
|
||||
|
||||
it('falls back to the field id when neither labelKey nor label resolves', () => {
|
||||
renderField({
|
||||
id: 'bare_field_id',
|
||||
type: 'single',
|
||||
options: [{ value: 'a', label: 'A' }]
|
||||
})
|
||||
expect(screen.getByText('bare_field_id')).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders the error message when provided', () => {
|
||||
renderField(singleField, { errorMessage: 'Please choose an option.' })
|
||||
expect(screen.getByText('Please choose an option.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -2,62 +2,72 @@
|
||||
<fieldset
|
||||
v-if="field.type !== 'text'"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
class="flex flex-col gap-4 border-0 p-0"
|
||||
class="m-0 flex flex-col gap-4 border-0 p-0"
|
||||
>
|
||||
<legend class="mb-2 block text-lg font-medium text-base-foreground">
|
||||
<legend class="mb-2 block text-lg font-medium text-primary-comfy-canvas">
|
||||
{{ resolvedLabel }}
|
||||
</legend>
|
||||
<template v-if="field.type === 'single'">
|
||||
<div
|
||||
<ToggleGroup
|
||||
v-if="field.type === 'single'"
|
||||
:model-value="(modelValue as string) ?? ''"
|
||||
type="single"
|
||||
class="flex w-full flex-col gap-2"
|
||||
@update:model-value="onSingleChange"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="option in field.options"
|
||||
:id="`${field.id}-${option.value}`"
|
||||
:key="option.value"
|
||||
class="flex items-center gap-3"
|
||||
:value="option.value"
|
||||
:class="optionCardClass"
|
||||
>
|
||||
<RadioButton
|
||||
:model-value="(modelValue as string) ?? ''"
|
||||
:input-id="`${field.id}-${option.value}`"
|
||||
:name="field.id"
|
||||
:value="option.value"
|
||||
:dt="checkedTokens"
|
||||
@update:model-value="onSingleChange"
|
||||
<i
|
||||
v-if="option.icon"
|
||||
:class="
|
||||
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label
|
||||
:for="`${field.id}-${option.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ resolveOptionLabel(option) }}</label
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
|
||||
<i :class="checkMarkClass" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
v-else
|
||||
:model-value="(modelValue as string[]) ?? []"
|
||||
type="multiple"
|
||||
class="flex w-full flex-col gap-2"
|
||||
@update:model-value="onMultiChange"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="option in field.options"
|
||||
:id="`${field.id}-${option.value}`"
|
||||
:key="option.value"
|
||||
class="flex items-center gap-3"
|
||||
:value="option.value"
|
||||
:class="optionCardClass"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="(modelValue as string[]) ?? []"
|
||||
:input-id="`${field.id}-${option.value}`"
|
||||
:value="option.value"
|
||||
:dt="checkedTokens"
|
||||
@update:model-value="onMultiChange"
|
||||
<i
|
||||
v-if="option.icon"
|
||||
:class="
|
||||
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label
|
||||
:for="`${field.id}-${option.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ resolveOptionLabel(option) }}</label
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
|
||||
<i :class="checkMarkClass" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Input
|
||||
v-if="field.allowOther && field.otherFieldId && modelValue === 'other'"
|
||||
v-if="field.allowOther && field.otherFieldId && isOtherSelected"
|
||||
:model-value="(otherValue as string) ?? ''"
|
||||
:class="inputClass"
|
||||
:maxlength="OTHER_TEXT_MAX_LENGTH"
|
||||
:placeholder="
|
||||
$t(
|
||||
`cloudOnboarding.survey.options.${field.id}.otherPlaceholder`,
|
||||
$t('cloudOnboarding.survey.otherPlaceholder')
|
||||
)
|
||||
"
|
||||
class="ml-1"
|
||||
@update:model-value="onOtherChange"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
|
||||
@@ -65,7 +75,7 @@
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<label
|
||||
:for="controlId"
|
||||
class="block text-lg font-medium text-base-foreground"
|
||||
class="block text-lg font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ resolvedLabel }}
|
||||
</label>
|
||||
@@ -74,6 +84,7 @@
|
||||
:model-value="(modelValue as string) ?? ''"
|
||||
:placeholder="field.placeholder"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
:class="inputClass"
|
||||
@update:model-value="onTextChange"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
|
||||
@@ -81,18 +92,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import { useId } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import type {
|
||||
LocalizedString,
|
||||
OnboardingSurveyField,
|
||||
OnboardingSurveyOption
|
||||
} from '@/platform/remoteConfig/types'
|
||||
|
||||
import { OTHER_TEXT_MAX_LENGTH } from './surveySchema'
|
||||
|
||||
const {
|
||||
field,
|
||||
modelValue,
|
||||
@@ -113,25 +126,31 @@ const emit = defineEmits<{
|
||||
const { t, te, locale } = useI18n()
|
||||
const controlId = useId()
|
||||
|
||||
const optionCardClass =
|
||||
'group h-auto w-full items-center justify-start gap-3 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 px-4 py-3 text-left text-sm text-primary-comfy-canvas shadow-inset-highlight transition-colors hover:bg-sand-300/20 data-[state=on]:bg-sand-300/15 data-[state=on]:ring-1 data-[state=on]:ring-inset data-[state=on]:ring-brand-yellow'
|
||||
|
||||
const checkMarkClass =
|
||||
'icon-[lucide--check] size-4 shrink-0 text-brand-yellow opacity-0 group-data-[state=on]:opacity-100'
|
||||
|
||||
const inputClass =
|
||||
'border-smoke-800/10 bg-smoke-800/10 text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/50 focus-visible:ring-inset'
|
||||
|
||||
const isOtherSelected = computed(() =>
|
||||
Array.isArray(modelValue)
|
||||
? modelValue.includes('other')
|
||||
: modelValue === 'other'
|
||||
)
|
||||
|
||||
const resolveLocalized = (value: LocalizedString): string => {
|
||||
if (typeof value === 'string') return value
|
||||
return value[locale.value] ?? value.en ?? Object.values(value)[0] ?? ''
|
||||
}
|
||||
|
||||
const checkedTokens = {
|
||||
checked: {
|
||||
background: 'var(--color-electric-400)',
|
||||
borderColor: 'var(--color-electric-400)',
|
||||
hoverBackground: 'var(--color-electric-400)',
|
||||
hoverBorderColor: 'var(--color-electric-400)'
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedLabel = (() => {
|
||||
const resolvedLabel = computed(() => {
|
||||
if (field.labelKey && te(field.labelKey)) return t(field.labelKey)
|
||||
if (field.label != null) return resolveLocalized(field.label)
|
||||
return field.id
|
||||
})()
|
||||
})
|
||||
|
||||
const resolveOptionLabel = (option: OnboardingSurveyOption): string => {
|
||||
if (option.labelKey && te(option.labelKey)) return t(option.labelKey)
|
||||
@@ -143,13 +162,10 @@ const onSingleChange = (value: unknown) => {
|
||||
emit('update:modelValue', typeof value === 'string' ? value : '')
|
||||
}
|
||||
const onMultiChange = (value: unknown) => {
|
||||
if (!Array.isArray(value)) {
|
||||
emit('update:modelValue', [])
|
||||
return
|
||||
}
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
emit(
|
||||
'update:modelValue',
|
||||
value.filter((v): v is string => typeof v === 'string')
|
||||
selected.filter((v): v is string => typeof v === 'string')
|
||||
)
|
||||
}
|
||||
const onTextChange = (value: string | number | undefined) => {
|
||||
|
||||
@@ -1,320 +1,383 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
import DynamicSurveyForm from './DynamicSurveyForm.vue'
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: 'Help us tailor your ComfyUI experience.',
|
||||
errors: {
|
||||
chooseAnOption: 'Please choose an option.',
|
||||
selectAtLeastOne: 'Please select at least one option.',
|
||||
describeAnswer: 'Please describe your answer.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
import { defaultOnboardingSurvey } from './defaultSurveySchema'
|
||||
|
||||
const renderForm = (survey: OnboardingSurvey) =>
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, i18n] },
|
||||
global: {
|
||||
plugins: [
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
]
|
||||
},
|
||||
props: { survey }
|
||||
})
|
||||
|
||||
const clickOption = (user: ReturnType<typeof userEvent.setup>, label: string) =>
|
||||
user.click(screen.getByText(label))
|
||||
|
||||
const firstSubmitPayload = (
|
||||
emitted: Record<string, unknown[]>
|
||||
): Record<string, unknown> | undefined =>
|
||||
(emitted.submit?.[0] as [Record<string, unknown>] | undefined)?.[0]
|
||||
|
||||
const twoStepSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
introKey: 'cloudOnboarding.survey.intro',
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
label: 'How do you plan to use ComfyUI?',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'personal', label: 'Personal use' },
|
||||
{ value: 'work', label: 'Work' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'intent',
|
||||
type: 'multi',
|
||||
label: 'What do you want to create with ComfyUI?',
|
||||
type: 'single',
|
||||
label: 'What do you want to make?',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'images', label: 'Images' },
|
||||
{ value: 'videos', label: 'Videos' }
|
||||
{ value: 'video', label: 'Video' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
label: 'Pick everything that applies',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'a', label: 'Making A' },
|
||||
{ value: 'b', label: 'Making B' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('DynamicSurveyForm', () => {
|
||||
it('renders the intro text and the first field options', () => {
|
||||
renderForm(twoStepSurvey)
|
||||
const branchedSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'intent',
|
||||
type: 'single',
|
||||
label: 'What do you want to make?',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'workflows', label: 'Workflows' },
|
||||
{ value: 'images', label: 'Images' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'focus',
|
||||
type: 'single',
|
||||
label: 'What are you building?',
|
||||
required: true,
|
||||
showWhen: { field: 'intent', equals: 'workflows' },
|
||||
options: [{ value: 'custom_nodes', label: 'Custom nodes' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.getByText('Help us tailor your ComfyUI experience.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
|
||||
expect(screen.getByLabelText('Personal use')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Work')).toBeInTheDocument()
|
||||
describe('DynamicSurveyForm', () => {
|
||||
it('renders the real default schema (v3) with its first question and options', () => {
|
||||
expect(defaultOnboardingSurvey.version).toBe(3)
|
||||
const firstField = defaultOnboardingSurvey.fields[0]!
|
||||
renderForm(defaultOnboardingSurvey)
|
||||
|
||||
expect(screen.getByText('What do you want to make?')).toBeVisible()
|
||||
expect(screen.getByText('Images')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(
|
||||
firstField.options!.length
|
||||
)
|
||||
})
|
||||
|
||||
it('disables Next until the user selects an option, then advances', async () => {
|
||||
it('auto-advances when a single-select option is chosen', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
const next = screen.getByRole('button', { name: 'Next' })
|
||||
expect(next).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByLabelText('Personal use'))
|
||||
expect(next).toBeEnabled()
|
||||
|
||||
await user.click(next)
|
||||
await flushPromises()
|
||||
// No Next click — choosing the card advances the wizard.
|
||||
await clickOption(user, 'Images')
|
||||
|
||||
expect(
|
||||
screen.getByText('What do you want to create with ComfyUI?')
|
||||
await screen.findByText('Pick everything that applies')
|
||||
).toBeVisible()
|
||||
expect(screen.getByLabelText('Images')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not auto-advance a multi-select step; Submit gates on a choice', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
await clickOption(user, 'Images')
|
||||
|
||||
const submit = await screen.findByRole('button', { name: 'Submit' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
await clickOption(user, 'Making A')
|
||||
// Still on the multi step (no auto-advance), now submittable.
|
||||
expect(screen.getByText('Pick everything that applies')).toBeVisible()
|
||||
await waitFor(() => expect(submit).toBeEnabled())
|
||||
})
|
||||
|
||||
it('navigates back to the previous step', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
await user.click(screen.getByLabelText('Personal use'))
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
await flushPromises()
|
||||
await clickOption(user, 'Images')
|
||||
expect(
|
||||
screen.getByText('What do you want to create with ComfyUI?')
|
||||
await screen.findByText('Pick everything that applies')
|
||||
).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }))
|
||||
await flushPromises()
|
||||
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
|
||||
expect(await screen.findByText('What do you want to make?')).toBeVisible()
|
||||
})
|
||||
|
||||
it('resolves option and field labels via labelKey when provided', () => {
|
||||
const localizedI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: 'Help us tailor your ComfyUI experience.',
|
||||
errors: {
|
||||
chooseAnOption: '',
|
||||
selectAtLeastOne: '',
|
||||
describeAnswer: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
survey_label: 'Localized question?',
|
||||
survey_a: 'Localized A',
|
||||
survey_b: 'Localized B'
|
||||
}
|
||||
}
|
||||
})
|
||||
it('offers Next on an already-answered single-select reached via Back', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, localizedI18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'q',
|
||||
type: 'single',
|
||||
labelKey: 'survey_label',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'a', labelKey: 'survey_a' },
|
||||
{ value: 'b', labelKey: 'survey_b' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
await clickOption(user, 'Images')
|
||||
await screen.findByText('Pick everything that applies')
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }))
|
||||
|
||||
expect(screen.getByText('Localized question?')).toBeVisible()
|
||||
expect(screen.getByLabelText('Localized A')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Localized B')).toBeInTheDocument()
|
||||
const next = await screen.findByRole('button', { name: 'Next' })
|
||||
await user.click(next)
|
||||
expect(
|
||||
await screen.findByText('Pick everything that applies')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders server-supplied translations from a label locale map', () => {
|
||||
const koreanI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'ko',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: '',
|
||||
errors: {
|
||||
chooseAnOption: '',
|
||||
selectAtLeastOne: '',
|
||||
describeAnswer: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ko: { g: { back: '뒤로', next: '다음', submit: '제출' } }
|
||||
}
|
||||
})
|
||||
it('reveals a branched follow-up step from the answer and submits it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderForm(branchedSurvey)
|
||||
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, koreanI18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
label: {
|
||||
en: 'How will you use it?',
|
||||
ko: '어떻게 사용하시겠어요?'
|
||||
},
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
value: 'personal',
|
||||
label: { en: 'Personal use', ko: '개인 용도' }
|
||||
},
|
||||
{ value: 'work', label: { en: 'Work', ko: '업무' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
await clickOption(user, 'Workflows')
|
||||
expect(await screen.findByText('What are you building?')).toBeVisible()
|
||||
|
||||
expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible()
|
||||
expect(screen.getByLabelText('개인 용도')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('업무')).toBeInTheDocument()
|
||||
await clickOption(user, 'Custom nodes')
|
||||
await user.click(await screen.findByRole('button', { name: 'Submit' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(firstSubmitPayload(emitted())).toEqual({
|
||||
intent: 'workflows',
|
||||
focus: 'custom_nodes'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to English when current locale missing from label map', () => {
|
||||
const fallbackI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: '',
|
||||
errors: {
|
||||
chooseAnOption: '',
|
||||
selectAtLeastOne: '',
|
||||
describeAnswer: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
fr: {}
|
||||
}
|
||||
})
|
||||
it('hides the branched step when the answer does not match', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderForm(branchedSurvey)
|
||||
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, fallbackI18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'q',
|
||||
type: 'single',
|
||||
label: { en: 'English question', ko: '한국어' },
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'a', label: { en: 'English A', ko: '한국어 A' } }
|
||||
]
|
||||
}
|
||||
// 'images' is the last visible step (focus hidden) → Submit, no branch.
|
||||
await clickOption(user, 'Images')
|
||||
const submit = await screen.findByRole('button', { name: 'Submit' })
|
||||
expect(screen.queryByText('What are you building?')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(submit)
|
||||
await waitFor(() =>
|
||||
expect(firstSubmitPayload(emitted())).toEqual({
|
||||
intent: 'images',
|
||||
focus: ''
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('requires the "other" free-text before submitting, then submits it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const otherSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'source',
|
||||
type: 'single',
|
||||
label: 'How did you find us?',
|
||||
required: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'sourceOther',
|
||||
options: [
|
||||
{ value: 'search', label: 'Web search' },
|
||||
{ value: 'other', label: 'Somewhere else' }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
const { emitted } = renderForm(otherSurvey)
|
||||
|
||||
// Selecting 'other' must NOT auto-advance — the text box is required.
|
||||
await clickOption(user, 'Somewhere else')
|
||||
const submit = await screen.findByRole('button', { name: 'Submit' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('Where did you find us?'),
|
||||
'A newsletter'
|
||||
)
|
||||
await waitFor(() => expect(submit).toBeEnabled())
|
||||
|
||||
await user.click(submit)
|
||||
await waitFor(() =>
|
||||
expect(firstSubmitPayload(emitted())).toEqual({ source: 'A newsletter' })
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces the free-text error once "other" text is touched then cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const otherSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'source',
|
||||
type: 'single',
|
||||
label: 'How did you find us?',
|
||||
required: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'sourceOther',
|
||||
options: [
|
||||
{ value: 'search', label: 'Web search' },
|
||||
{ value: 'other', label: 'Somewhere else' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
renderForm(otherSurvey)
|
||||
|
||||
await clickOption(user, 'Somewhere else')
|
||||
const input = await screen.findByPlaceholderText('Where did you find us?')
|
||||
// Type then clear → the free-text field is touched but empty, so its
|
||||
// required error surfaces.
|
||||
await user.type(input, 'x')
|
||||
await user.clear(input)
|
||||
expect(
|
||||
await screen.findByText('Please describe your answer.')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows a required-field error only after the user interacts, not before', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm({
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
label: 'Pick everything that applies',
|
||||
required: true,
|
||||
options: [{ value: 'a', label: 'Making A' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// fr is not in the map → falls back to en
|
||||
expect(screen.getByText('English question')).toBeVisible()
|
||||
expect(screen.getByLabelText('English A')).toBeInTheDocument()
|
||||
// No error on first render (field untouched).
|
||||
expect(
|
||||
screen.queryByText('Please select at least one option.')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
// Select then clear → field is touched but empty → error surfaces.
|
||||
await user.click(screen.getByText('Making A'))
|
||||
await user.click(screen.getByText('Making A'))
|
||||
expect(
|
||||
await screen.findByText('Please select at least one option.')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('allows advancing past an optional field while still empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, i18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'q1',
|
||||
type: 'single',
|
||||
label: 'Optional question?',
|
||||
options: [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' }
|
||||
]
|
||||
// no required: true — should be skippable
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
type: 'single',
|
||||
label: 'Required question?',
|
||||
required: true,
|
||||
options: [{ value: 'c', label: 'C' }]
|
||||
}
|
||||
renderForm({
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'q1',
|
||||
type: 'single',
|
||||
label: 'Optional question?',
|
||||
options: [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' }
|
||||
]
|
||||
// no required: true — should be skippable
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
type: 'single',
|
||||
label: 'Required question?',
|
||||
required: true,
|
||||
options: [{ value: 'c', label: 'C' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const next = screen.getByRole('button', { name: 'Next' })
|
||||
expect(next).toBeEnabled()
|
||||
|
||||
await user.click(next)
|
||||
await flushPromises()
|
||||
expect(screen.getByText('Required question?')).toBeVisible()
|
||||
expect(await screen.findByText('Required question?')).toBeVisible()
|
||||
})
|
||||
|
||||
it('enables Submit only after the multi-select field has at least one choice', async () => {
|
||||
it('resets to the first step when the survey prop changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
const { rerender } = render(DynamicSurveyForm, {
|
||||
global: {
|
||||
plugins: [
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
]
|
||||
},
|
||||
props: { survey: twoStepSurvey }
|
||||
})
|
||||
|
||||
await user.click(screen.getByLabelText('Work'))
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
await flushPromises()
|
||||
await clickOption(user, 'Images')
|
||||
expect(
|
||||
await screen.findByText('Pick everything that applies')
|
||||
).toBeVisible()
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Submit' })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
await rerender({ survey: branchedSurvey })
|
||||
// Back on step 0 of the new survey (no Back button on the first step).
|
||||
expect(await screen.findByText('What do you want to make?')).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Back' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /Images/i }))
|
||||
await flushPromises()
|
||||
expect(submitBtn).toBeEnabled()
|
||||
it('renders server-supplied label translations and falls back to English', () => {
|
||||
render(DynamicSurveyForm, {
|
||||
global: {
|
||||
plugins: [
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'ko',
|
||||
fallbackLocale: 'en',
|
||||
messages: { en: enMessages, ko: { g: { next: '다음' } } }
|
||||
})
|
||||
]
|
||||
},
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'intent',
|
||||
type: 'single',
|
||||
label: { en: 'What will you make?', ko: '무엇을 만들 건가요?' },
|
||||
required: true,
|
||||
options: [
|
||||
// ko provided → localized; ko missing → English fallback
|
||||
{ value: 'images', label: { en: 'Images', ko: '이미지' } },
|
||||
{ value: 'video', label: { en: 'Video' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('무엇을 만들 건가요?')).toBeVisible()
|
||||
expect(screen.getByText('이미지')).toBeInTheDocument()
|
||||
expect(screen.getByText('Video')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,109 +1,118 @@
|
||||
<template>
|
||||
<form class="flex size-full flex-col" @submit.prevent="onSubmit">
|
||||
<p v-if="introText" class="mb-4 text-sm text-muted">
|
||||
<form class="flex w-full flex-col" @submit.prevent="onSubmit">
|
||||
<p v-if="introText" class="mb-4 text-sm text-muted-foreground">
|
||||
{{ introText }}
|
||||
</p>
|
||||
<div
|
||||
class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background"
|
||||
class="mb-8 h-1.5 w-full overflow-hidden rounded-full bg-primary-comfy-canvas/10"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-electric-400 transition-[width] duration-300 ease-out"
|
||||
class="h-full bg-brand-yellow transition-[width] duration-300 ease-out"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="currentField"
|
||||
:key="currentField.id"
|
||||
class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1"
|
||||
>
|
||||
<DynamicSurveyField
|
||||
:field="currentField"
|
||||
:model-value="values[currentField.id]"
|
||||
:other-value="
|
||||
currentField.otherFieldId
|
||||
? (values[currentField.otherFieldId] as string)
|
||||
: undefined
|
||||
"
|
||||
:error-message="
|
||||
errors[currentField.id] ??
|
||||
(currentField.otherFieldId
|
||||
? errors[currentField.otherFieldId]
|
||||
: undefined)
|
||||
"
|
||||
@update:model-value="(value) => onFieldChange(currentField.id, value)"
|
||||
@update:other-value="
|
||||
(value) =>
|
||||
currentField.otherFieldId &&
|
||||
onFieldChange(currentField.otherFieldId, value)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="overflow-hidden transition-[height] duration-300 ease-out"
|
||||
:style="animatedHeightStyle"
|
||||
>
|
||||
<div ref="questionContent" class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
leave-active-class="absolute inset-x-0 top-0 transition-opacity duration-300 ease-out"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="currentField"
|
||||
:key="currentField.id"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<DynamicSurveyField
|
||||
:field="currentField"
|
||||
:model-value="values[currentField.id]"
|
||||
:other-value="
|
||||
currentField.otherFieldId
|
||||
? (values[currentField.otherFieldId] as string)
|
||||
: undefined
|
||||
"
|
||||
:error-message="currentError"
|
||||
@update:model-value="
|
||||
(value) => void onFieldChange(currentField.id, value)
|
||||
"
|
||||
@update:other-value="
|
||||
(value) =>
|
||||
currentField.otherFieldId &&
|
||||
void onFieldChange(currentField.otherFieldId, value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<div
|
||||
v-if="!isFirst || showNext || isLast"
|
||||
class="mt-8 flex items-center justify-between gap-4"
|
||||
>
|
||||
<Button
|
||||
v-if="!isFirst"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="h-10 flex-1 text-white"
|
||||
variant="link"
|
||||
size="lg"
|
||||
class="px-0 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
|
||||
@click="goPrevious"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-4" aria-hidden="true" />
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<span v-else class="flex-1" />
|
||||
<span v-else />
|
||||
<Button
|
||||
v-if="!isLast"
|
||||
v-if="showNext"
|
||||
type="button"
|
||||
size="lg"
|
||||
:disabled="!isCurrentValid"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 flex-1 border-none',
|
||||
isCurrentValid
|
||||
? 'bg-electric-400 text-black hover:bg-electric-400/85'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
)
|
||||
"
|
||||
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
v-else-if="isLast"
|
||||
type="submit"
|
||||
size="lg"
|
||||
:disabled="!isCurrentValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 flex-1 border-none',
|
||||
isCurrentValid && !isSubmitting
|
||||
? 'bg-electric-400 text-black hover:bg-electric-400/85'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
)
|
||||
"
|
||||
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
|
||||
>
|
||||
{{ $t('g.submit') }}
|
||||
</Button>
|
||||
<span v-else />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
OnboardingSurvey,
|
||||
OnboardingSurveyField
|
||||
} from '@/platform/remoteConfig/types'
|
||||
|
||||
import DynamicSurveyField from './DynamicSurveyField.vue'
|
||||
import {
|
||||
buildInitialValues,
|
||||
buildSubmissionPayload,
|
||||
buildZodSchema,
|
||||
hasNonEmptyValue,
|
||||
isOtherValue,
|
||||
prepareSurvey,
|
||||
visibleFields
|
||||
} from './surveySchema'
|
||||
@@ -147,6 +156,8 @@ watch(
|
||||
liveValues.value = { ...fresh }
|
||||
resetForm({ values: fresh })
|
||||
stepIndex.value = 0
|
||||
touched.value = new Set()
|
||||
isAdvancing.value = false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -154,11 +165,43 @@ const visible = computed(() =>
|
||||
visibleFields(preparedSurvey.value, values as SurveyValues)
|
||||
)
|
||||
const stepIndex = ref(0)
|
||||
const touched = ref(new Set<string>())
|
||||
const isAdvancing = ref(false)
|
||||
|
||||
const questionContent = useTemplateRef<HTMLElement>('questionContent')
|
||||
const { height: contentHeight } = useElementSize(questionContent)
|
||||
const animatedHeightStyle = computed(() =>
|
||||
contentHeight.value ? { height: `${contentHeight.value}px` } : {}
|
||||
)
|
||||
|
||||
const currentField = computed(() => visible.value[stepIndex.value])
|
||||
const isFirst = computed(() => stepIndex.value === 0)
|
||||
const isLast = computed(() => stepIndex.value === visible.value.length - 1)
|
||||
|
||||
const showNext = computed(() => {
|
||||
if (isLast.value || isAdvancing.value) return false
|
||||
const field = currentField.value
|
||||
if (!field) return false
|
||||
if (field.type !== 'single') return true
|
||||
return !(field.required && !hasNonEmptyValue(values[field.id]))
|
||||
})
|
||||
|
||||
const currentError = computed(() => {
|
||||
const field = currentField.value
|
||||
if (!field) return undefined
|
||||
if (touched.value.has(field.id) && errors.value[field.id]) {
|
||||
return errors.value[field.id]
|
||||
}
|
||||
if (
|
||||
field.otherFieldId &&
|
||||
touched.value.has(field.otherFieldId) &&
|
||||
errors.value[field.otherFieldId]
|
||||
) {
|
||||
return errors.value[field.otherFieldId]
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const totalSteps = computed(() => Math.max(visible.value.length, 1))
|
||||
const progressPercent = computed(() =>
|
||||
Math.max(
|
||||
@@ -172,26 +215,41 @@ const isCurrentValid = computed(() => {
|
||||
if (!field) return false
|
||||
|
||||
const value = values[field.id]
|
||||
const isEmpty =
|
||||
field.type === 'multi'
|
||||
? !Array.isArray(value) || value.length === 0
|
||||
: typeof value !== 'string' || value.length === 0
|
||||
if (!hasNonEmptyValue(value)) return !field.required
|
||||
|
||||
if (isEmpty) return !field.required
|
||||
|
||||
if (field.allowOther && field.otherFieldId && value === 'other') {
|
||||
if (field.allowOther && field.otherFieldId && isOtherValue(value)) {
|
||||
const other = values[field.otherFieldId]
|
||||
return typeof other === 'string' && other.trim().length > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const onFieldChange = (id: string, value: string | string[]) => {
|
||||
const isAutoAdvanceValue = (field: OnboardingSurveyField, value: unknown) =>
|
||||
field.type === 'single' &&
|
||||
typeof value === 'string' &&
|
||||
value !== '' &&
|
||||
value !== 'other'
|
||||
|
||||
const markTouched = (id: string) => {
|
||||
touched.value = new Set(touched.value).add(id)
|
||||
}
|
||||
|
||||
const onFieldChange = async (id: string, value: string | string[]) => {
|
||||
if (isAdvancing.value) return
|
||||
markTouched(id)
|
||||
setFieldValue(id, value)
|
||||
liveValues.value = { ...liveValues.value, [id]: value }
|
||||
if (stepIndex.value > visible.value.length - 1) {
|
||||
stepIndex.value = Math.max(0, visible.value.length - 1)
|
||||
}
|
||||
|
||||
const field = currentField.value
|
||||
if (field?.id === id && isAutoAdvanceValue(field, value)) {
|
||||
isAdvancing.value = true
|
||||
await nextTick()
|
||||
goNext()
|
||||
isAdvancing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
@@ -202,6 +260,11 @@ const goPrevious = () => {
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const field = currentField.value
|
||||
if (field) {
|
||||
markTouched(field.id)
|
||||
if (field.otherFieldId) markTouched(field.otherFieldId)
|
||||
}
|
||||
const result = await validate()
|
||||
if (!result.valid) return
|
||||
emit(
|
||||
|
||||
@@ -1,55 +1,61 @@
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
OnboardingSurvey,
|
||||
OnboardingSurveyOption
|
||||
} from '@/platform/remoteConfig/types'
|
||||
|
||||
const optionsFor = (
|
||||
fieldId: string,
|
||||
values: string[]
|
||||
): { value: string; labelKey: string }[] =>
|
||||
values: string[],
|
||||
icons: Record<string, string> = {}
|
||||
): OnboardingSurveyOption[] =>
|
||||
values.map((value) => ({
|
||||
value,
|
||||
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`
|
||||
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`,
|
||||
...(icons[value] ? { icon: icons[value] } : {})
|
||||
}))
|
||||
|
||||
export const defaultOnboardingSurvey: OnboardingSurvey = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
introKey: 'cloudOnboarding.survey.intro',
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_usage',
|
||||
required: true,
|
||||
options: optionsFor('usage', ['personal', 'work', 'education'])
|
||||
},
|
||||
{
|
||||
id: 'familiarity',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_familiarity',
|
||||
required: true,
|
||||
options: optionsFor('familiarity', [
|
||||
'new',
|
||||
'starting',
|
||||
'basics',
|
||||
'advanced',
|
||||
'expert'
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'intent',
|
||||
type: 'multi',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_intent',
|
||||
required: true,
|
||||
randomize: true,
|
||||
options: optionsFor('intent', [
|
||||
'workflows',
|
||||
'custom_nodes',
|
||||
'videos',
|
||||
'images',
|
||||
'3d_game',
|
||||
'audio',
|
||||
'apps',
|
||||
'api',
|
||||
'not_sure'
|
||||
])
|
||||
allowOther: true,
|
||||
otherFieldId: 'intentOther',
|
||||
options: optionsFor(
|
||||
'intent',
|
||||
['images', 'video', 'workflows', 'apps_api', 'exploring', 'other'],
|
||||
{
|
||||
images: 'icon-[lucide--image]',
|
||||
video: 'icon-[lucide--video]',
|
||||
workflows: 'icon-[lucide--workflow]',
|
||||
apps_api: 'icon-[lucide--blocks]',
|
||||
exploring: 'icon-[lucide--compass]',
|
||||
other: 'icon-[lucide--pencil]'
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'experience',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_experience',
|
||||
required: true,
|
||||
options: optionsFor('experience', ['new', 'some', 'pro'], {
|
||||
new: 'icon-[lucide--sprout]',
|
||||
some: 'icon-[lucide--map]',
|
||||
pro: 'icon-[lucide--rocket]'
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'focus',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_focus',
|
||||
required: true,
|
||||
showWhen: { field: 'intent', equals: ['workflows', 'apps_api'] },
|
||||
options: optionsFor('focus', ['custom_nodes', 'pipelines', 'products'])
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
@@ -57,19 +63,31 @@ export const defaultOnboardingSurvey: OnboardingSurvey = {
|
||||
labelKey: 'cloudSurvey_steps_source',
|
||||
required: true,
|
||||
randomize: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'sourceOther',
|
||||
options: optionsFor('source', [
|
||||
'social',
|
||||
'friend',
|
||||
'search',
|
||||
'community',
|
||||
'other'
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'source_social',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_source_social',
|
||||
required: true,
|
||||
randomize: true,
|
||||
showWhen: { field: 'source', equals: 'social' },
|
||||
options: optionsFor('source_social', [
|
||||
'youtube',
|
||||
'reddit',
|
||||
'twitter',
|
||||
'instagram',
|
||||
'tiktok',
|
||||
'linkedin',
|
||||
'friend',
|
||||
'search',
|
||||
'newsletter',
|
||||
'conference',
|
||||
'discord',
|
||||
'github',
|
||||
'other'
|
||||
'discord'
|
||||
])
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { defaultOnboardingSurvey } from './defaultSurveySchema'
|
||||
import {
|
||||
OTHER_TEXT_MAX_LENGTH,
|
||||
buildInitialValues,
|
||||
buildSubmissionPayload,
|
||||
buildZodSchema,
|
||||
hasNonEmptyValue,
|
||||
prepareSurvey,
|
||||
visibleFields
|
||||
} from './surveySchema'
|
||||
@@ -246,3 +249,179 @@ describe('prepareSurvey', () => {
|
||||
expect(values.slice(0, -2).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultOnboardingSurvey branching', () => {
|
||||
const idsFor = (values: Record<string, string | string[]>) =>
|
||||
visibleFields(defaultOnboardingSurvey, values).map((f) => f.id)
|
||||
|
||||
it('asks only the core steps when no branch condition is met', () => {
|
||||
expect(idsFor({ intent: 'images', source: 'friend' })).toEqual([
|
||||
'intent',
|
||||
'experience',
|
||||
'source'
|
||||
])
|
||||
})
|
||||
|
||||
it('asks every step when both branches are active', () => {
|
||||
expect(idsFor({ intent: 'workflows', source: 'social' })).toEqual([
|
||||
'intent',
|
||||
'experience',
|
||||
'focus',
|
||||
'source',
|
||||
'source_social'
|
||||
])
|
||||
})
|
||||
|
||||
it('asks focus only for builder intents (workflows / apps_api)', () => {
|
||||
expect(idsFor({ intent: 'workflows' })).toContain('focus')
|
||||
expect(idsFor({ intent: 'apps_api' })).toContain('focus')
|
||||
expect(idsFor({ intent: 'images' })).not.toContain('focus')
|
||||
expect(idsFor({ intent: 'exploring' })).not.toContain('focus')
|
||||
})
|
||||
|
||||
it('asks source_social only when source is social', () => {
|
||||
expect(idsFor({ source: 'social' })).toContain('source_social')
|
||||
expect(idsFor({ source: 'friend' })).not.toContain('source_social')
|
||||
})
|
||||
|
||||
it('zeroes hidden branch fields in the submission payload', () => {
|
||||
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
|
||||
intent: 'images',
|
||||
experience: 'new',
|
||||
source: 'friend'
|
||||
})
|
||||
expect(payload).toMatchObject({
|
||||
intent: 'images',
|
||||
experience: 'new',
|
||||
source: 'friend',
|
||||
focus: '',
|
||||
source_social: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers free-text over the "other" sentinel for intent and source', () => {
|
||||
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
|
||||
intent: 'other',
|
||||
intentOther: ' Comics ',
|
||||
experience: 'pro',
|
||||
source: 'other',
|
||||
sourceOther: 'A podcast'
|
||||
})
|
||||
expect(payload.intent).toBe('Comics')
|
||||
expect(payload.source).toBe('A podcast')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasNonEmptyValue', () => {
|
||||
const cases: [string | string[] | undefined, boolean][] = [
|
||||
[undefined, false],
|
||||
['', false],
|
||||
[[], false],
|
||||
['a', true],
|
||||
[['a'], true],
|
||||
[['a', 'b'], true]
|
||||
]
|
||||
it.for(cases)('treats %o as non-empty=%o', ([value, expected]) => {
|
||||
expect(hasNonEmptyValue(value)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-select allowOther', () => {
|
||||
const multiOtherSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
required: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'makingOther',
|
||||
options: [
|
||||
{ value: 'a', labelKey: 'a' },
|
||||
{ value: 'other', labelKey: 'other' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
it('requires the free-text when a multi field includes "other"', () => {
|
||||
const schema = buildZodSchema(multiOtherSurvey, {
|
||||
making: ['a', 'other'],
|
||||
makingOther: ''
|
||||
})
|
||||
expect(
|
||||
schema.safeParse({ making: ['a', 'other'], makingOther: '' }).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({ making: ['a', 'other'], makingOther: 'Comics' })
|
||||
.success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not require the free-text when "other" is not among the choices', () => {
|
||||
const schema = buildZodSchema(multiOtherSurvey, {
|
||||
making: ['a'],
|
||||
makingOther: ''
|
||||
})
|
||||
expect(schema.safeParse({ making: ['a'], makingOther: '' }).success).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the array and surfaces the trimmed free-text separately', () => {
|
||||
const payload = buildSubmissionPayload(multiOtherSurvey, {
|
||||
making: ['a', 'other'],
|
||||
makingOther: ' Comics '
|
||||
})
|
||||
expect(payload.making).toEqual(['a', 'other'])
|
||||
expect(payload.makingOther).toBe('Comics')
|
||||
})
|
||||
})
|
||||
|
||||
describe('other free-text validation', () => {
|
||||
const otherSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'source',
|
||||
type: 'single',
|
||||
required: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'sourceOther',
|
||||
options: [
|
||||
{ value: 'search', labelKey: 'search' },
|
||||
{ value: 'other', labelKey: 'other' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
it('rejects a whitespace-only "other" answer', () => {
|
||||
const schema = buildZodSchema(otherSurvey, {
|
||||
source: 'other',
|
||||
sourceOther: ' '
|
||||
})
|
||||
expect(
|
||||
schema.safeParse({ source: 'other', sourceOther: ' ' }).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an "other" answer longer than the max length', () => {
|
||||
const schema = buildZodSchema(otherSurvey, {
|
||||
source: 'other',
|
||||
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
|
||||
})
|
||||
expect(
|
||||
schema.safeParse({
|
||||
source: 'other',
|
||||
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({
|
||||
source: 'other',
|
||||
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH)
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,12 +9,19 @@ import type {
|
||||
|
||||
export type SurveyValues = Record<string, string | string[] | undefined>
|
||||
|
||||
const hasNonEmptyValue = (current: string | string[] | undefined): boolean => {
|
||||
export const OTHER_TEXT_MAX_LENGTH = 200
|
||||
|
||||
export const hasNonEmptyValue = (
|
||||
current: string | string[] | undefined
|
||||
): boolean => {
|
||||
if (current === undefined || current === '') return false
|
||||
if (Array.isArray(current)) return current.length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
export const isOtherValue = (current: string | string[] | undefined): boolean =>
|
||||
Array.isArray(current) ? current.includes('other') : current === 'other'
|
||||
|
||||
const conditionMatches = (
|
||||
condition: OnboardingSurveyFieldCondition | undefined,
|
||||
values: SurveyValues
|
||||
@@ -54,7 +61,7 @@ export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({
|
||||
fields: survey.fields.map(randomizeOptions)
|
||||
})
|
||||
|
||||
type Translator = (key: string) => string
|
||||
type Translator = (key: string, named?: Record<string, unknown>) => string
|
||||
|
||||
const identityTranslator: Translator = (key) => key
|
||||
|
||||
@@ -87,11 +94,19 @@ export const buildZodSchema = (
|
||||
if (
|
||||
field.allowOther &&
|
||||
field.otherFieldId &&
|
||||
values[field.id] === 'other'
|
||||
isOtherValue(values[field.id])
|
||||
) {
|
||||
shape[field.otherFieldId] = z.string().min(1, {
|
||||
message: t('cloudOnboarding.survey.errors.describeAnswer')
|
||||
})
|
||||
shape[field.otherFieldId] = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, {
|
||||
message: t('cloudOnboarding.survey.errors.describeAnswer')
|
||||
})
|
||||
.max(OTHER_TEXT_MAX_LENGTH, {
|
||||
message: t('cloudOnboarding.survey.errors.answerTooLong', {
|
||||
max: OTHER_TEXT_MAX_LENGTH
|
||||
})
|
||||
})
|
||||
} else if (field.otherFieldId) {
|
||||
shape[field.otherFieldId] = z.string().optional()
|
||||
}
|
||||
@@ -120,17 +135,23 @@ export const buildSubmissionPayload = (
|
||||
continue
|
||||
}
|
||||
const value = values[field.id]
|
||||
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
|
||||
if (
|
||||
const otherFieldId = field.otherFieldId
|
||||
const otherRaw = otherFieldId ? values[otherFieldId] : undefined
|
||||
const otherText =
|
||||
field.allowOther &&
|
||||
field.otherFieldId &&
|
||||
value === 'other' &&
|
||||
otherFieldId &&
|
||||
isOtherValue(value) &&
|
||||
typeof otherRaw === 'string'
|
||||
) {
|
||||
const other = otherRaw.trim()
|
||||
payload[field.id] = other || 'other'
|
||||
? otherRaw.trim()
|
||||
: undefined
|
||||
|
||||
if (otherText !== undefined && field.type !== 'multi') {
|
||||
payload[field.id] = otherText || 'other'
|
||||
} else {
|
||||
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
|
||||
if (otherText !== undefined && otherFieldId) {
|
||||
payload[otherFieldId] = otherText
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload
|
||||
|
||||
@@ -44,6 +44,7 @@ export type OnboardingSurveyOption = {
|
||||
value: string
|
||||
label?: LocalizedString
|
||||
labelKey?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export type OnboardingSurveyFieldCondition = {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<img
|
||||
v-if="option.logo"
|
||||
:src="option.logo"
|
||||
:alt="option.label"
|
||||
alt=""
|
||||
class="size-4"
|
||||
/>
|
||||
{{ option.label }}
|
||||
|
||||
@@ -268,6 +268,27 @@ describe('useSecretForm', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('passes a server-listed provider absent from the local registry through with its raw id as label and no logo', () => {
|
||||
const visible = ref(true)
|
||||
const { providerOptions } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
availableProviders: () => ['brand-new-provider'],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
expect(providerOptions.value).toEqual([
|
||||
{
|
||||
value: 'brand-new-provider',
|
||||
label: 'brand-new-provider',
|
||||
logo: undefined,
|
||||
disabled: false
|
||||
}
|
||||
])
|
||||
expect(providerOptions.value[0]?.logo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits BYOK providers the server does not list', () => {
|
||||
const visible = ref(true)
|
||||
const { providerOptions } = useSecretForm({
|
||||
|
||||
@@ -101,8 +101,11 @@ export function useSecretForm(options: UseSecretFormOptions) {
|
||||
|
||||
// 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)) {
|
||||
watch(providerOptions, (resolvedOptions) => {
|
||||
if (
|
||||
form.provider &&
|
||||
!resolvedOptions.some((o) => o.value === form.provider)
|
||||
) {
|
||||
form.provider = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,6 +39,11 @@ export function useSettingsDialog() {
|
||||
// breaks those nested dialogs' autofocus and click handling. Non-modal
|
||||
// keeps the visual overlay without those traps.
|
||||
modal: false,
|
||||
// A nested dialog closing (e.g. confirming a Secrets delete) can move
|
||||
// focus onto an app element once the row it focused is removed. As a
|
||||
// non-modal dialog Settings would treat that as an outside focus and
|
||||
// dismiss itself, so opt out — escape and outside clicks still close it.
|
||||
dismissOnFocusOutside: false,
|
||||
size: 'full',
|
||||
contentClass: SETTINGS_CONTENT_CLASS,
|
||||
overlayClass: isWorkspaceMode ? 'p-8' : undefined
|
||||
|
||||
@@ -1215,6 +1215,15 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: isCloud ? true : false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
name: 'Use the asset browser for the model library',
|
||||
type: 'hidden',
|
||||
tooltip:
|
||||
'When enabled alongside the asset API, the model library opens the asset browser. Otherwise it opens the sidebar tree.',
|
||||
defaultValue: isCloud ? true : false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VersionCompatibility.DisableWarnings',
|
||||
name: 'Disable version compatibility warnings',
|
||||
|
||||
@@ -52,20 +52,25 @@ export interface AuthMetadata {
|
||||
utm_campaign?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Survey response data for user profiling
|
||||
* Maps 1-to-1 with actual survey fields
|
||||
*/
|
||||
/** Survey field ids → answers. Fields are backend-overridable, so all optional. */
|
||||
export interface SurveyResponses {
|
||||
// Current default schema (see defaultSurveySchema.ts)
|
||||
intent?: string | string[]
|
||||
intentOther?: string
|
||||
experience?: string
|
||||
focus?: string
|
||||
source?: string
|
||||
sourceOther?: string
|
||||
source_social?: string
|
||||
// Legacy fields — only emitted by older backend-supplied schemas, never by
|
||||
// the current default. Kept so historical responses still typecheck.
|
||||
familiarity?: string
|
||||
industry?: string
|
||||
useCase?: string
|
||||
making?: string[]
|
||||
role?: string
|
||||
teamSize?: string
|
||||
source?: string
|
||||
usage?: string
|
||||
intent?: string[]
|
||||
}
|
||||
|
||||
export interface SurveyResponsesNormalized extends SurveyResponses {
|
||||
|
||||
@@ -428,6 +428,7 @@ const zSettings = z.object({
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy.ModelLibrary.UseAssetBrowser': z.boolean(),
|
||||
'Comfy.Queue.QPOV2': z.boolean(),
|
||||
'Comfy.Queue.ShowRunProgressBar': z.boolean(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
|
||||
@@ -875,6 +875,113 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination safety', () => {
|
||||
it('stops instead of looping when the backend ignores offset', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
|
||||
// A backend that ignores offset returns the same full page every time.
|
||||
const fullPage = Array.from({ length: 500 }, (_, i) =>
|
||||
createMockAsset(`asset-${i}`)
|
||||
)
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(fullPage)
|
||||
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(2)
|
||||
expect(store.getAssets(nodeType)).toHaveLength(500)
|
||||
})
|
||||
|
||||
it('continues past an all-duplicate page whose content differs from the previous page', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
|
||||
// Concurrent writes can shift pagination windows so a page is all
|
||||
// already-seen assets without the backend ignoring offset; later pages
|
||||
// can still hold unseen assets.
|
||||
const fullPage = Array.from({ length: 500 }, (_, i) =>
|
||||
createMockAsset(`asset-${i}`)
|
||||
)
|
||||
const samePageReordered = [...fullPage].reverse()
|
||||
const finalPage = [createMockAsset('late-arrival')]
|
||||
|
||||
let callCount = 0
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockImplementation(
|
||||
async () => {
|
||||
callCount++
|
||||
if (callCount === 1) return fullPage
|
||||
if (callCount === 2) return samePageReordered
|
||||
return finalPage
|
||||
}
|
||||
)
|
||||
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
expect(store.getAssets(nodeType).map((a) => a.id)).toContain(
|
||||
'late-arrival'
|
||||
)
|
||||
})
|
||||
|
||||
it('terminates when an offset-ignoring backend alternates page orderings', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
|
||||
// Same full page served forever with a nondeterministic ordering: no
|
||||
// page ever contributes a new ID, and no two consecutive pages are
|
||||
// identical. The walk must still stop.
|
||||
const fullPage = Array.from({ length: 500 }, (_, i) =>
|
||||
createMockAsset(`asset-${i}`)
|
||||
)
|
||||
const reversed = [...fullPage].reverse()
|
||||
let callCount = 0
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockImplementation(
|
||||
async () => {
|
||||
callCount++
|
||||
return callCount % 2 === 1 ? fullPage : reversed
|
||||
}
|
||||
)
|
||||
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
|
||||
expect(callCount).toBeLessThanOrEqual(5)
|
||||
expect(store.getAssets(nodeType)).toHaveLength(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh error surfacing', () => {
|
||||
it('surfaces a failed refresh on the committed state consumers read', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
|
||||
createMockAsset('existing')
|
||||
])
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
expect(store.getError(nodeType)).toBeUndefined()
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce(
|
||||
new Error('backend down')
|
||||
)
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
|
||||
expect(store.getAssets(nodeType).map((a) => a.id)).toEqual(['existing'])
|
||||
expect(store.getError(nodeType)?.message).toBe('backend down')
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent request handling', () => {
|
||||
it('should short-circuit concurrent calls to prevent duplicate work', async () => {
|
||||
const store = useAssetsStore()
|
||||
@@ -924,6 +1031,34 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a newer request single-flighted when a stale request finishes after invalidation', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
|
||||
let resolveFirst!: (assets: AssetItem[]) => void
|
||||
const firstFetch = new Promise<AssetItem[]>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
.mockReturnValueOnce(firstFetch)
|
||||
.mockReturnValue(new Promise<AssetItem[]>(() => {}))
|
||||
|
||||
const staleRequest = store.updateModelsForNodeType(nodeType)
|
||||
store.invalidateCategory('checkpoints')
|
||||
void store.updateModelsForNodeType(nodeType)
|
||||
|
||||
resolveFirst([createMockAsset('stale')])
|
||||
await staleRequest
|
||||
|
||||
// The stale request's teardown must not evict the newer request's
|
||||
// single-flight entry: a third call short-circuits instead of starting
|
||||
// a duplicate walk.
|
||||
void store.updateModelsForNodeType(nodeType)
|
||||
expect(
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shallowReactive state reactivity', () => {
|
||||
@@ -1435,6 +1570,35 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Model Assets Cache (non-cloud)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockIsCloud.value = false
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('caches model assets fetched by tag on non-cloud builds', async () => {
|
||||
const store = useAssetsStore()
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([
|
||||
{
|
||||
id: 'm1',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
tags: ['checkpoints', 'models']
|
||||
},
|
||||
{ id: 'm2', name: 'lora.safetensors', tags: ['loras', 'models'] }
|
||||
])
|
||||
|
||||
await store.updateModelsForTag('models')
|
||||
|
||||
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
|
||||
'models',
|
||||
true,
|
||||
expect.anything()
|
||||
)
|
||||
expect(store.getAssets('tag:models')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -394,421 +394,444 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
* Multiple node types sharing the same category share the same cache entry.
|
||||
* Public API accepts nodeType for backwards compatibility but translates
|
||||
* to category internally using modelToNodeStore.getCategoryForNodeType().
|
||||
* Cloud-only feature - empty Maps in desktop builds
|
||||
*
|
||||
* Runs on every distribution; whether anything fetches through it is
|
||||
* decided by consumers via `assetService.isAssetAPIEnabled()`, which stays
|
||||
* the authoritative off-cloud gate.
|
||||
*/
|
||||
const getModelState = () => {
|
||||
if (isCloud) {
|
||||
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
|
||||
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
|
||||
|
||||
const assetsArrayCache = new Map<
|
||||
string,
|
||||
{ source: Map<string, AssetItem>; array: AssetItem[] }
|
||||
>()
|
||||
const assetsArrayCache = new Map<
|
||||
string,
|
||||
{ source: Map<string, AssetItem>; array: AssetItem[] }
|
||||
>()
|
||||
|
||||
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
|
||||
const pendingPromiseByCategory = new Map<string, Promise<void>>()
|
||||
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
|
||||
const pendingPromiseByCategory = new Map<string, Promise<void>>()
|
||||
|
||||
function createState(
|
||||
existingAssets?: Map<string, AssetItem>
|
||||
): ModelPaginationState {
|
||||
const assets = new Map(existingAssets)
|
||||
return reactive({
|
||||
assets,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
isLoading: true
|
||||
})
|
||||
function createState(
|
||||
existingAssets?: Map<string, AssetItem>
|
||||
): ModelPaginationState {
|
||||
const assets = new Map(existingAssets)
|
||||
return reactive({
|
||||
assets,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
isLoading: true
|
||||
})
|
||||
}
|
||||
|
||||
function isStale(category: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
const pending = pendingRequestByCategory.get(category)
|
||||
return committed !== state && pending !== state
|
||||
}
|
||||
|
||||
const EMPTY_ASSETS: AssetItem[] = []
|
||||
|
||||
/**
|
||||
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
* @returns The category or undefined if not resolvable
|
||||
*/
|
||||
function resolveCategory(key: string): string | undefined {
|
||||
if (key.startsWith('tag:')) {
|
||||
return key
|
||||
}
|
||||
return modelToNodeStore.getCategoryForNodeType(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by nodeType or tag key.
|
||||
* Translates nodeType to category internally for cache lookup.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
*/
|
||||
function getAssets(key: string): AssetItem[] {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return EMPTY_ASSETS
|
||||
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
const assetsMap = state?.assets
|
||||
if (!assetsMap) return EMPTY_ASSETS
|
||||
|
||||
const cached = assetsArrayCache.get(category)
|
||||
if (cached && cached.source === assetsMap) {
|
||||
return cached.array
|
||||
}
|
||||
|
||||
function isStale(category: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
const pending = pendingRequestByCategory.get(category)
|
||||
return committed !== state && pending !== state
|
||||
const array = Array.from(assetsMap.values())
|
||||
assetsArrayCache.set(category, { source: assetsMap, array })
|
||||
return array
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.isLoading ?? false
|
||||
}
|
||||
|
||||
function getError(key: string): Error | undefined {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return undefined
|
||||
return modelStateByCategory.value.get(category)?.error
|
||||
}
|
||||
|
||||
function hasMore(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.hasMore ?? false
|
||||
}
|
||||
|
||||
function hasAssetKey(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
* Keeps existing data visible until new data is successfully fetched.
|
||||
*
|
||||
* Concurrent calls for the same category are short-circuited: if a request
|
||||
* is already in progress (tracked via pendingRequestByCategory), subsequent
|
||||
* calls return immediately to avoid redundant work.
|
||||
*/
|
||||
async function updateModelsForCategory(
|
||||
category: string,
|
||||
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
|
||||
): Promise<void> {
|
||||
if (pendingPromiseByCategory.has(category)) {
|
||||
return pendingPromiseByCategory.get(category)!
|
||||
}
|
||||
|
||||
const EMPTY_ASSETS: AssetItem[] = []
|
||||
const existingState = modelStateByCategory.value.get(category)
|
||||
const state = createState(existingState?.assets)
|
||||
|
||||
/**
|
||||
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
* @returns The category or undefined if not resolvable
|
||||
*/
|
||||
function resolveCategory(key: string): string | undefined {
|
||||
if (key.startsWith('tag:')) {
|
||||
return key
|
||||
}
|
||||
return modelToNodeStore.getCategoryForNodeType(key)
|
||||
const seenIds = new Set<string>()
|
||||
const seenPageSignatures = new Set<string>()
|
||||
let consecutiveNoProgressPages = 0
|
||||
|
||||
const hasExistingData = modelStateByCategory.value.has(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.set(category, state)
|
||||
} else {
|
||||
// Also track in pending map for initial loads to prevent concurrent calls
|
||||
pendingRequestByCategory.set(category, state)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by nodeType or tag key.
|
||||
* Translates nodeType to category internally for cache lookup.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
*/
|
||||
function getAssets(key: string): AssetItem[] {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return EMPTY_ASSETS
|
||||
async function loadBatches(): Promise<void> {
|
||||
while (state.hasMore) {
|
||||
try {
|
||||
const newAssets = await fetcher({
|
||||
limit: MODEL_BATCH_SIZE,
|
||||
offset: state.offset
|
||||
})
|
||||
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
const assetsMap = state?.assets
|
||||
if (!assetsMap) return EMPTY_ASSETS
|
||||
if (isStale(category, state)) return
|
||||
|
||||
const cached = assetsArrayCache.get(category)
|
||||
if (cached && cached.source === assetsMap) {
|
||||
return cached.array
|
||||
}
|
||||
|
||||
const array = Array.from(assetsMap.values())
|
||||
assetsArrayCache.set(category, { source: assetsMap, array })
|
||||
return array
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.isLoading ?? false
|
||||
}
|
||||
|
||||
function getError(key: string): Error | undefined {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return undefined
|
||||
return modelStateByCategory.value.get(category)?.error
|
||||
}
|
||||
|
||||
function hasMore(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.hasMore ?? false
|
||||
}
|
||||
|
||||
function hasAssetKey(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
* Keeps existing data visible until new data is successfully fetched.
|
||||
*
|
||||
* Concurrent calls for the same category are short-circuited: if a request
|
||||
* is already in progress (tracked via pendingRequestByCategory), subsequent
|
||||
* calls return immediately to avoid redundant work.
|
||||
*/
|
||||
async function updateModelsForCategory(
|
||||
category: string,
|
||||
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
|
||||
): Promise<void> {
|
||||
if (pendingPromiseByCategory.has(category)) {
|
||||
return pendingPromiseByCategory.get(category)!
|
||||
}
|
||||
|
||||
const existingState = modelStateByCategory.value.get(category)
|
||||
const state = createState(existingState?.assets)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const hasExistingData = modelStateByCategory.value.has(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.set(category, state)
|
||||
} else {
|
||||
// Also track in pending map for initial loads to prevent concurrent calls
|
||||
pendingRequestByCategory.set(category, state)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
while (state.hasMore) {
|
||||
try {
|
||||
const newAssets = await fetcher({
|
||||
limit: MODEL_BATCH_SIZE,
|
||||
offset: state.offset
|
||||
})
|
||||
|
||||
if (isStale(category, state)) return
|
||||
|
||||
const isFirstBatch = state.offset === 0
|
||||
if (isFirstBatch) {
|
||||
assetsArrayCache.delete(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
const isFirstBatch = state.offset === 0
|
||||
if (isFirstBatch) {
|
||||
assetsArrayCache.delete(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
// Merge new assets into existing map and track seen IDs
|
||||
for (const asset of newAssets) {
|
||||
seenIds.add(asset.id)
|
||||
state.assets.set(asset.id, asset)
|
||||
}
|
||||
state.assets = new Map(state.assets)
|
||||
|
||||
state.offset += newAssets.length
|
||||
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
|
||||
|
||||
if (isFirstBatch) {
|
||||
state.isLoading = false
|
||||
}
|
||||
|
||||
if (state.hasMore) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale(category, state)) return
|
||||
console.error(`Error loading batch for ${category}:`, err)
|
||||
|
||||
state.error = err instanceof Error ? err : new Error(String(err))
|
||||
state.hasMore = false
|
||||
state.isLoading = false
|
||||
pendingRequestByCategory.delete(category)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const staleIds = [...state.assets.keys()].filter(
|
||||
(id) => !seenIds.has(id)
|
||||
)
|
||||
for (const id of staleIds) {
|
||||
state.assets.delete(id)
|
||||
// Merge new assets into existing map and track seen IDs
|
||||
const uniqueIdsBefore = seenIds.size
|
||||
for (const asset of newAssets) {
|
||||
seenIds.add(asset.id)
|
||||
state.assets.set(asset.id, asset)
|
||||
}
|
||||
state.assets = new Map(state.assets)
|
||||
|
||||
// Termination guards for backends that do not honour `offset`.
|
||||
// A page whose exact ID sequence was already served means the
|
||||
// walk is cycling, however the pages are ordered — stop. A single
|
||||
// all-duplicate page with fresh content (concurrent writes
|
||||
// shifting pagination windows) keeps going, but a run of them
|
||||
// with no new IDs is treated as exhausted so reordered responses
|
||||
// can never loop forever.
|
||||
const batchSignature = newAssets.map((asset) => asset.id).join(',')
|
||||
const isRepeatedPage =
|
||||
newAssets.length > 0 && seenPageSignatures.has(batchSignature)
|
||||
seenPageSignatures.add(batchSignature)
|
||||
const madeProgress = seenIds.size > uniqueIdsBefore
|
||||
consecutiveNoProgressPages = madeProgress
|
||||
? 0
|
||||
: consecutiveNoProgressPages + 1
|
||||
state.offset += newAssets.length
|
||||
state.hasMore =
|
||||
newAssets.length === MODEL_BATCH_SIZE &&
|
||||
!isRepeatedPage &&
|
||||
consecutiveNoProgressPages < 3
|
||||
|
||||
if (isFirstBatch) {
|
||||
state.isLoading = false
|
||||
}
|
||||
|
||||
if (state.hasMore) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale(category, state)) return
|
||||
console.error(`Error loading batch for ${category}:`, err)
|
||||
|
||||
state.error = err instanceof Error ? err : new Error(String(err))
|
||||
state.hasMore = false
|
||||
state.isLoading = false
|
||||
// A refresh that fails before its first batch never replaces the
|
||||
// committed state, so mirror the error onto the state consumers
|
||||
// actually read (getError) instead of only the discarded one.
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
if (committed && committed !== state) {
|
||||
committed.error = state.error
|
||||
}
|
||||
if (pendingRequestByCategory.get(category) === state) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
assetsArrayCache.delete(category)
|
||||
}
|
||||
|
||||
const staleIds = [...state.assets.keys()].filter(
|
||||
(id) => !seenIds.has(id)
|
||||
)
|
||||
for (const id of staleIds) {
|
||||
state.assets.delete(id)
|
||||
}
|
||||
assetsArrayCache.delete(category)
|
||||
if (pendingRequestByCategory.get(category) === state) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
}
|
||||
|
||||
const promise = loadBatches().finally(() => {
|
||||
// Guard both cleanups: an invalidateCategory during an awaited fetch
|
||||
// lets a newer request register its own entries before this one's
|
||||
// teardown runs, and an unconditional delete would evict the newer
|
||||
// request's entry and break single-flighting.
|
||||
const promise = loadBatches().finally(() => {
|
||||
if (pendingPromiseByCategory.get(category) === promise) {
|
||||
pendingPromiseByCategory.delete(category)
|
||||
})
|
||||
pendingPromiseByCategory.set(category, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type.
|
||||
* Translates nodeType to category internally - multiple node types
|
||||
* sharing the same category will share the same cache entry.
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
*/
|
||||
async function updateModelsForNodeType(nodeType: string): Promise<void> {
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
if (!category) return
|
||||
|
||||
// Use category as cache key but fetch using nodeType for API compatibility
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsForNodeType(nodeType, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific tag
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<void> {
|
||||
const category = `tag:${tag}`
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsByTag(tag, true, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache for a specific category.
|
||||
* Forces a refetch on next access.
|
||||
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function invalidateCategory(category: string): void {
|
||||
modelStateByCategory.value.delete(category)
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
pendingPromiseByCategory.delete(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update an asset in the cache
|
||||
* @param assetId The asset ID to update
|
||||
* @param updates Partial asset data to merge
|
||||
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
|
||||
*/
|
||||
function updateAssetInCache(
|
||||
assetId: string,
|
||||
updates: Partial<AssetItem>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const category = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (cacheKey && !category) return
|
||||
|
||||
const categoriesToCheck = category
|
||||
? [category]
|
||||
: Array.from(modelStateByCategory.value.keys())
|
||||
|
||||
for (const cat of categoriesToCheck) {
|
||||
const state = modelStateByCategory.value.get(cat)
|
||||
if (!state?.assets) continue
|
||||
|
||||
const existingAsset = state.assets.get(assetId)
|
||||
if (existingAsset) {
|
||||
const updatedAsset = { ...existingAsset, ...updates }
|
||||
state.assets.set(assetId, updatedAsset)
|
||||
assetsArrayCache.delete(cat)
|
||||
if (cacheKey) return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
pendingPromiseByCategory.set(category, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset metadata with optimistic cache update
|
||||
* @param asset The asset to update
|
||||
* @param userMetadata The user_metadata to save
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetMetadata(
|
||||
asset: AssetItem,
|
||||
userMetadata: Record<string, unknown>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalMetadata = asset.user_metadata
|
||||
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type.
|
||||
* Translates nodeType to category internally - multiple node types
|
||||
* sharing the same category will share the same cache entry.
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
*/
|
||||
async function updateModelsForNodeType(nodeType: string): Promise<void> {
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
if (!category) return
|
||||
|
||||
try {
|
||||
const updatedAsset = await assetService.updateAsset(asset.id, {
|
||||
user_metadata: userMetadata
|
||||
})
|
||||
updateAssetInCache(asset.id, updatedAsset, cacheKey)
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset metadata:', error)
|
||||
updateAssetInCache(
|
||||
asset.id,
|
||||
{ user_metadata: originalMetadata },
|
||||
cacheKey
|
||||
)
|
||||
// Use category as cache key but fetch using nodeType for API compatibility
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsForNodeType(nodeType, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific tag
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<void> {
|
||||
const category = `tag:${tag}`
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsByTag(tag, true, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache for a specific category.
|
||||
* Forces a refetch on next access.
|
||||
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function invalidateCategory(category: string): void {
|
||||
modelStateByCategory.value.delete(category)
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
pendingPromiseByCategory.delete(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update an asset in the cache
|
||||
* @param assetId The asset ID to update
|
||||
* @param updates Partial asset data to merge
|
||||
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
|
||||
*/
|
||||
function updateAssetInCache(
|
||||
assetId: string,
|
||||
updates: Partial<AssetItem>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const category = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (cacheKey && !category) return
|
||||
|
||||
const categoriesToCheck = category
|
||||
? [category]
|
||||
: Array.from(modelStateByCategory.value.keys())
|
||||
|
||||
for (const cat of categoriesToCheck) {
|
||||
const state = modelStateByCategory.value.get(cat)
|
||||
if (!state?.assets) continue
|
||||
|
||||
const existingAsset = state.assets.get(assetId)
|
||||
if (existingAsset) {
|
||||
const updatedAsset = { ...existingAsset, ...updates }
|
||||
state.assets.set(assetId, updatedAsset)
|
||||
assetsArrayCache.delete(cat)
|
||||
if (cacheKey) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset tags using add/remove endpoints
|
||||
* @param asset The asset to update (used to read current tags)
|
||||
* @param newTags The desired tags array
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetTags(
|
||||
asset: AssetItem,
|
||||
newTags: string[],
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalTags = asset.tags
|
||||
const tagsToAdd = difference(newTags, originalTags)
|
||||
const tagsToRemove = difference(originalTags, newTags)
|
||||
|
||||
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
|
||||
|
||||
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
|
||||
|
||||
let removedTagsOnServer: string[] = []
|
||||
try {
|
||||
let removeResult: TagsOperationResult | undefined
|
||||
if (tagsToRemove.length > 0) {
|
||||
removeResult = await assetService.removeAssetTags(
|
||||
asset.id,
|
||||
tagsToRemove
|
||||
)
|
||||
removedTagsOnServer = removeResult.removed ?? tagsToRemove
|
||||
}
|
||||
|
||||
const addResult =
|
||||
tagsToAdd.length > 0
|
||||
? await assetService.addAssetTags(asset.id, tagsToAdd)
|
||||
: undefined
|
||||
|
||||
const finalTags = (addResult ?? removeResult)?.total_tags
|
||||
if (finalTags) {
|
||||
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset tags:', error)
|
||||
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
|
||||
|
||||
if (removedTagsOnServer.length > 0) {
|
||||
try {
|
||||
await assetService.addAssetTags(asset.id, removedTagsOnServer)
|
||||
} catch (compensationError) {
|
||||
console.error(
|
||||
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
|
||||
compensationError
|
||||
)
|
||||
const categoriesToInvalidate = new Set<string>()
|
||||
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (resolved) {
|
||||
categoriesToInvalidate.add(resolved)
|
||||
}
|
||||
for (const [
|
||||
category,
|
||||
state
|
||||
] of modelStateByCategory.value.entries()) {
|
||||
if (state.assets?.has(asset.id)) {
|
||||
categoriesToInvalidate.add(category)
|
||||
}
|
||||
}
|
||||
for (const category of categoriesToInvalidate) {
|
||||
invalidateCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
const emptyAssets: AssetItem[] = []
|
||||
/**
|
||||
* Update asset metadata with optimistic cache update
|
||||
* @param asset The asset to update
|
||||
* @param userMetadata The user_metadata to save
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetMetadata(
|
||||
asset: AssetItem,
|
||||
userMetadata: Record<string, unknown>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalMetadata = asset.user_metadata
|
||||
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
|
||||
|
||||
try {
|
||||
const updatedAsset = await assetService.updateAsset(asset.id, {
|
||||
user_metadata: userMetadata
|
||||
})
|
||||
updateAssetInCache(asset.id, updatedAsset, cacheKey)
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset metadata:', error)
|
||||
updateAssetInCache(
|
||||
asset.id,
|
||||
{ user_metadata: originalMetadata },
|
||||
cacheKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset tags using add/remove endpoints
|
||||
* @param asset The asset to update (used to read current tags)
|
||||
* @param newTags The desired tags array
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetTags(
|
||||
asset: AssetItem,
|
||||
newTags: string[],
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalTags = asset.tags
|
||||
const tagsToAdd = difference(newTags, originalTags)
|
||||
const tagsToRemove = difference(originalTags, newTags)
|
||||
|
||||
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
|
||||
|
||||
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
|
||||
|
||||
let removedTagsOnServer: string[] = []
|
||||
try {
|
||||
let removeResult: TagsOperationResult | undefined
|
||||
if (tagsToRemove.length > 0) {
|
||||
removeResult = await assetService.removeAssetTags(
|
||||
asset.id,
|
||||
tagsToRemove
|
||||
)
|
||||
removedTagsOnServer = removeResult.removed ?? tagsToRemove
|
||||
}
|
||||
|
||||
const addResult =
|
||||
tagsToAdd.length > 0
|
||||
? await assetService.addAssetTags(asset.id, tagsToAdd)
|
||||
: undefined
|
||||
|
||||
const finalTags = (addResult ?? removeResult)?.total_tags
|
||||
if (finalTags) {
|
||||
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset tags:', error)
|
||||
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
|
||||
|
||||
if (removedTagsOnServer.length > 0) {
|
||||
try {
|
||||
await assetService.addAssetTags(asset.id, removedTagsOnServer)
|
||||
} catch (compensationError) {
|
||||
console.error(
|
||||
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
|
||||
compensationError
|
||||
)
|
||||
const categoriesToInvalidate = new Set<string>()
|
||||
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (resolved) {
|
||||
categoriesToInvalidate.add(resolved)
|
||||
}
|
||||
for (const [
|
||||
category,
|
||||
state
|
||||
] of modelStateByCategory.value.entries()) {
|
||||
if (state.assets?.has(asset.id)) {
|
||||
categoriesToInvalidate.add(category)
|
||||
}
|
||||
}
|
||||
for (const category of categoriesToInvalidate) {
|
||||
invalidateCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets: () => emptyAssets,
|
||||
isLoading: () => false,
|
||||
getError: () => undefined,
|
||||
hasMore: () => false,
|
||||
hasAssetKey: () => false,
|
||||
hasCategory: () => false,
|
||||
updateModelsForNodeType: async () => {},
|
||||
invalidateCategory: () => {},
|
||||
updateModelsForTag: async () => {},
|
||||
updateAssetMetadata: async () => {},
|
||||
updateAssetTags: async () => {},
|
||||
invalidateModelsForCategory: () => {}
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ interface CustomDialogComponentProps {
|
||||
pt?: DialogPassThroughOptions
|
||||
closeOnEscape?: boolean
|
||||
dismissableMask?: boolean
|
||||
/**
|
||||
* When `false`, the Reka dialog does not dismiss when focus leaves its
|
||||
* content. Set on container dialogs (e.g. Settings) that host nested dialogs,
|
||||
* where a nested dialog closing can move focus onto an ordinary app element
|
||||
* — a programmatic shift that must not be read as a dismiss. Escape and
|
||||
* outside-pointer dismissal are unaffected. Defaults to `true`.
|
||||
*/
|
||||
dismissOnFocusOutside?: boolean
|
||||
unstyled?: boolean
|
||||
headless?: boolean
|
||||
renderer?: DialogRenderer
|
||||
|
||||
@@ -5,7 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import {
|
||||
ResourceState,
|
||||
effectiveModelExtensions,
|
||||
matchesModelExtension,
|
||||
useModelStore
|
||||
} from '@/stores/modelStore'
|
||||
|
||||
// Mock the api
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -15,6 +20,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
viewMetadata: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
addCustomEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
@@ -22,8 +28,10 @@ vi.mock('@/scripts/api', () => ({
|
||||
// Mock the assetService
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetModelFolders: vi.fn(),
|
||||
getAssetModels: vi.fn()
|
||||
getAssetModels: vi.fn(),
|
||||
invalidateModelBuckets: vi.fn(),
|
||||
onModelsScanned: vi.fn(),
|
||||
seedModelAssets: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -57,16 +65,15 @@ function enableMocks(useAssetAPI = false) {
|
||||
{ name: 'vae', folders: ['/path/to/vae'] }
|
||||
])
|
||||
|
||||
// Mock asset API - also returns objects with name and folders properties
|
||||
vi.mocked(assetService.getAssetModelFolders).mockResolvedValue([
|
||||
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
|
||||
{ name: 'vae', folders: ['/path/to/vae'] }
|
||||
])
|
||||
// Asset API supplies only the per-folder model contents; folders come from
|
||||
// api.getModelFolders in both paths.
|
||||
vi.mocked(assetService.getAssetModels).mockResolvedValue([
|
||||
{ name: 'sdxl.safetensors', pathIndex: 0 },
|
||||
{ name: 'sdv15.safetensors', pathIndex: 0 },
|
||||
{ name: 'noinfo.safetensors', pathIndex: 0 }
|
||||
])
|
||||
vi.mocked(assetService.seedModelAssets).mockResolvedValue(undefined)
|
||||
vi.mocked(assetService.onModelsScanned).mockReturnValue(() => {})
|
||||
|
||||
vi.mocked(api.viewMetadata).mockImplementation((_, model) => {
|
||||
if (model === 'noinfo.safetensors') {
|
||||
@@ -209,6 +216,193 @@ describe('useModelStore', () => {
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kicks off a backend scan when models come from the asset API', async () => {
|
||||
enableMocks(true)
|
||||
store = useModelStore()
|
||||
|
||||
await store.refresh()
|
||||
|
||||
expect(assetService.seedModelAssets).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not scan on the legacy listing path', async () => {
|
||||
enableMocks(false)
|
||||
store = useModelStore()
|
||||
|
||||
await store.refresh()
|
||||
|
||||
expect(assetService.seedModelAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent folder loads', () => {
|
||||
it('does not let a stale folder response overwrite a fresher one', async () => {
|
||||
enableMocks()
|
||||
let resolveStale!: (value: { name: string; folders: string[] }[]) => void
|
||||
vi.mocked(api.getModelFolders).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveStale = resolve
|
||||
})
|
||||
)
|
||||
store = useModelStore()
|
||||
const staleLoad = store.loadModelFolders()
|
||||
|
||||
vi.mocked(api.getModelFolders).mockResolvedValueOnce([
|
||||
{ name: 'fresh-folder', folders: ['/fresh'] }
|
||||
])
|
||||
await store.loadModelFolders()
|
||||
expect(store.modelFolders.map((f) => f.directory)).toEqual([
|
||||
'fresh-folder'
|
||||
])
|
||||
|
||||
resolveStale([{ name: 'stale-folder', folders: ['/stale'] }])
|
||||
await staleLoad
|
||||
|
||||
expect(store.modelFolders.map((f) => f.directory)).toEqual([
|
||||
'fresh-folder'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('eager-loading before boot loads the folder structure first', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledWith('vae')
|
||||
})
|
||||
|
||||
describe('refreshModelFolder races', () => {
|
||||
it('keeps the newer refresh when an older one for the same folder finishes last', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
let resolveOld!: (value: { name: string; pathIndex: number }[]) => void
|
||||
vi.mocked(api.getModels).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve
|
||||
})
|
||||
)
|
||||
const oldRefresh = store.refreshModelFolder('checkpoints')
|
||||
|
||||
vi.mocked(api.getModels).mockResolvedValueOnce([
|
||||
{ name: 'newer.safetensors', pathIndex: 0 }
|
||||
])
|
||||
await store.refreshModelFolder('checkpoints')
|
||||
|
||||
resolveOld([{ name: 'older.safetensors', pathIndex: 0 }])
|
||||
await oldRefresh
|
||||
|
||||
const folder = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(folder!.models['0/newer.safetensors']).toBeDefined()
|
||||
expect(folder!.models['0/older.safetensors']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not resurrect a stale folder over a fresher structure', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
let resolveStaleContents!: (
|
||||
value: { name: string; pathIndex: number }[]
|
||||
) => void
|
||||
vi.mocked(api.getModels).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveStaleContents = resolve
|
||||
})
|
||||
)
|
||||
const staleRefresh = store.refreshModelFolder('checkpoints')
|
||||
|
||||
// A full reload rebuilds the folder structure mid-refresh.
|
||||
await store.loadModelFolders()
|
||||
const freshFolder = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
resolveStaleContents([{ name: 'stale.safetensors', pathIndex: 0 }])
|
||||
await staleRefresh
|
||||
|
||||
const current = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(current).toBe(freshFolder)
|
||||
expect(current!.models['0/stale.safetensors']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scan fast-phase completion', () => {
|
||||
it('re-loads folders whose eager load was still in flight when the reload fired', async () => {
|
||||
enableMocks(true)
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
|
||||
// Eager load starts but its fetch never lands before the scan event.
|
||||
let resolveEager!: (value: { name: string; pathIndex: number }[]) => void
|
||||
vi.mocked(assetService.getAssetModels).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveEager = resolve
|
||||
})
|
||||
)
|
||||
const eagerLoad = store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
const scanCallback = vi.mocked(assetService.onModelsScanned).mock
|
||||
.calls[0]?.[0]
|
||||
await scanCallback!()
|
||||
|
||||
// The rebuilt folder must have been re-loaded, not left uninitialized
|
||||
// while the original request finishes into a detached folder object.
|
||||
const folder = store.modelFolders.find(
|
||||
(f) => f.directory === 'checkpoints'
|
||||
)
|
||||
expect(folder!.state).toBe(ResourceState.Loaded)
|
||||
|
||||
resolveEager([{ name: 'detached.safetensors', pathIndex: 0 }])
|
||||
await eagerLoad
|
||||
const current = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(current!.models['0/detached.safetensors']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-loads previously loaded folders when the event fires', async () => {
|
||||
enableMocks(true)
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
|
||||
|
||||
const scanCallback = vi.mocked(assetService.onModelsScanned).mock
|
||||
.calls[0]?.[0]
|
||||
expect(scanCallback).toBeDefined()
|
||||
await scanCallback!()
|
||||
await vi.waitFor(() => {
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(assetService.invalidateModelBuckets).toHaveBeenCalled()
|
||||
expect(assetService.seedModelAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs instead of rejecting when the post-scan reload fails', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
enableMocks(true)
|
||||
vi.mocked(api.getModelFolders).mockRejectedValue(
|
||||
new Error('transient network failure')
|
||||
)
|
||||
store = useModelStore()
|
||||
const scanCallback = vi.mocked(assetService.onModelsScanned).mock
|
||||
.calls[0]?.[0]
|
||||
|
||||
await scanCallback!()
|
||||
await vi.waitFor(() => {
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reload'),
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
error.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API switching functionality', () => {
|
||||
@@ -218,28 +412,117 @@ describe('useModelStore', () => {
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
|
||||
// Folders come from /experiment/models; legacy path also serves models.
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(0)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
|
||||
expect(folderStore).toBeDefined()
|
||||
expect(Object.keys(folderStore!.models)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
|
||||
it('should use asset API for model contents but /experiment/models for folders when UseAssetAPI is true', async () => {
|
||||
enableMocks(true) // useAssetAPI = true
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
|
||||
// Folders always come from /experiment/models; only contents use the asset API.
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledWith('checkpoints')
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(0)
|
||||
expect(api.getModels).toHaveBeenCalledTimes(0)
|
||||
expect(folderStore).toBeDefined()
|
||||
expect(Object.keys(folderStore!.models)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filters asset-path folder contents by the folder extensions', async () => {
|
||||
enableMocks(true)
|
||||
vi.mocked(api.getModelFolders).mockResolvedValue([
|
||||
{ name: 'checkpoints', folders: ['/p'], extensions: ['.safetensors'] }
|
||||
])
|
||||
vi.mocked(assetService.getAssetModels).mockResolvedValue([
|
||||
{ name: 'keep.safetensors', pathIndex: 0 },
|
||||
{ name: 'notes.txt', pathIndex: 0 }
|
||||
])
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folder = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
const names = Object.values(folder!.models).map((m) => m.file_name)
|
||||
expect(names).toEqual(['keep.safetensors'])
|
||||
})
|
||||
|
||||
it('hides non-model noise in match-all folders on the asset path', async () => {
|
||||
enableMocks(true)
|
||||
vi.mocked(api.getModelFolders).mockResolvedValue([
|
||||
{ name: 'LLM', folders: ['/p'], extensions: [] }
|
||||
])
|
||||
vi.mocked(assetService.getAssetModels).mockResolvedValue([
|
||||
{ name: 'model.safetensors', pathIndex: 0 },
|
||||
{ name: 'README.md', pathIndex: 0 }
|
||||
])
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folder = await store.getLoadedModelFolder('LLM')
|
||||
|
||||
const names = Object.values(folder!.models).map((m) => m.file_name)
|
||||
expect(names).toEqual(['model.safetensors'])
|
||||
})
|
||||
|
||||
it('leaves the legacy listing unfiltered', async () => {
|
||||
enableMocks(false)
|
||||
vi.mocked(api.getModelFolders).mockResolvedValue([
|
||||
{ name: 'checkpoints', folders: ['/p'], extensions: ['.safetensors'] }
|
||||
])
|
||||
vi.mocked(api.getModels).mockResolvedValue([
|
||||
{ name: 'keep.safetensors', pathIndex: 0 },
|
||||
{ name: 'legacy-visible.gguf', pathIndex: 0 }
|
||||
])
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folder = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
const names = Object.values(folder!.models).map((m) => m.file_name)
|
||||
expect(names).toEqual(['keep.safetensors', 'legacy-visible.gguf'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(matchesModelExtension, () => {
|
||||
it('keeps files whose extension is in the folder list', () => {
|
||||
expect(
|
||||
matchesModelExtension('a.safetensors', ['.safetensors', '.ckpt'])
|
||||
).toBe(true)
|
||||
expect(matchesModelExtension('a.txt', ['.safetensors'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matches case-insensitively and on subpaths', () => {
|
||||
expect(
|
||||
matchesModelExtension('sub/dir/A.SAFETENSORS', ['.safetensors'])
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is permissive when there are no real extensions', () => {
|
||||
// Unfiltered folders (empty) and the `folder`/`''` sentinels show everything.
|
||||
expect(matchesModelExtension('readme.md', [])).toBe(true)
|
||||
expect(matchesModelExtension('anything', ['folder'])).toBe(true)
|
||||
expect(matchesModelExtension('anything', [''])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(effectiveModelExtensions, () => {
|
||||
it('uses a registered allowlist verbatim', () => {
|
||||
expect(effectiveModelExtensions(['.gguf'])).toEqual(['.gguf'])
|
||||
})
|
||||
|
||||
it('substitutes the default list for match-all folders', () => {
|
||||
const effective = effectiveModelExtensions([])
|
||||
expect(effective).toContain('.safetensors')
|
||||
expect(matchesModelExtension('readme.md', effective)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an absent field (older backends) like match-all', () => {
|
||||
expect(effectiveModelExtensions(undefined)).toEqual(
|
||||
effectiveModelExtensions([])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import type { ModelFile } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -100,6 +101,11 @@ export class ComfyModelDef {
|
||||
if (this.has_loaded_metadata || this.is_load_requested) {
|
||||
return
|
||||
}
|
||||
// viewMetadata reads the safetensors header off local disk; on Cloud the
|
||||
// model bytes live in object storage so there is nothing to read.
|
||||
if (isCloud) {
|
||||
return
|
||||
}
|
||||
this.is_load_requested = true
|
||||
try {
|
||||
const metadata = await api.viewMetadata(this.directory, this.file_name)
|
||||
@@ -156,6 +162,66 @@ export enum ResourceState {
|
||||
Loaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the preview image for a model: embedded metadata thumbnail when
|
||||
* loaded, otherwise the server-rendered `.webp` preview. The preview endpoint
|
||||
* reads a rendered thumbnail off local disk, which is unavailable on Cloud
|
||||
* (model bytes live in object storage), so Cloud resolves to no preview.
|
||||
*/
|
||||
export function getModelPreviewUrl(model: ComfyModelDef): string {
|
||||
if (model.image) return model.image
|
||||
if (isCloud) return ''
|
||||
const extension = model.file_name.split('.').pop()
|
||||
const filename = model.file_name.replace(`.${extension}`, '.webp')
|
||||
const encodedFilename = encodeURIComponent(filename).replace(/%2F/g, '/')
|
||||
return `/api/experiment/models/preview/${model.directory}/${model.path_index}/${encodedFilename}`
|
||||
}
|
||||
|
||||
/**
|
||||
* FE-owned copy of core's default `supported_pt_extensions`, applied to
|
||||
* match-all folders (empty registered allowlist) so they don't surface
|
||||
* README/config noise. Accepted to go stale across core version bumps; the
|
||||
* whole surface is expected to be short-lived.
|
||||
*/
|
||||
const DEFAULT_MODEL_EXTENSIONS = [
|
||||
'.ckpt',
|
||||
'.pt',
|
||||
'.pt2',
|
||||
'.bin',
|
||||
'.pth',
|
||||
'.safetensors',
|
||||
'.pkl',
|
||||
'.sft'
|
||||
]
|
||||
|
||||
/**
|
||||
* Resolves a folder's display allowlist from its raw registered `extensions`
|
||||
* (`/experiment/models`): non-empty is used verbatim; an empty array
|
||||
* (match-all) or an absent field (older backends) takes the FE default list,
|
||||
* reproducing the legacy sidebar's global-set behavior so nothing that used
|
||||
* to be hidden starts showing.
|
||||
*/
|
||||
export function effectiveModelExtensions(
|
||||
extensions: string[] | undefined
|
||||
): string[] {
|
||||
return extensions?.length ? extensions : DEFAULT_MODEL_EXTENSIONS
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a model file belongs in a folder given its display allowlist. An
|
||||
* empty list, or a list with no real (`.`-prefixed) extensions (the
|
||||
* `'folder'`/`''` sentinels), leaves the folder unfiltered.
|
||||
*/
|
||||
export function matchesModelExtension(
|
||||
fileName: string,
|
||||
extensions: string[]
|
||||
): boolean {
|
||||
const realExtensions = extensions.filter((ext) => ext.startsWith('.'))
|
||||
if (realExtensions.length === 0) return true
|
||||
const lower = fileName.toLowerCase()
|
||||
return realExtensions.some((ext) => lower.endsWith(ext.toLowerCase()))
|
||||
}
|
||||
|
||||
export class ModelFolder {
|
||||
/** Models in this folder */
|
||||
models: Record<string, ComfyModelDef> = {}
|
||||
@@ -163,7 +229,8 @@ export class ModelFolder {
|
||||
|
||||
constructor(
|
||||
public directory: string,
|
||||
private getModelsFunc: (folder: string) => Promise<ModelFile[]>
|
||||
private getModelsFunc: (folder: string) => Promise<ModelFile[]>,
|
||||
public readonly extensions: string[] = []
|
||||
) {}
|
||||
|
||||
get key(): string {
|
||||
@@ -180,6 +247,7 @@ export class ModelFolder {
|
||||
this.state = ResourceState.Loading
|
||||
const models = await this.getModelsFunc(this.directory)
|
||||
for (const model of models) {
|
||||
if (!matchesModelExtension(model.name, this.extensions)) continue
|
||||
this.models[`${model.pathIndex}/${model.name}`] = new ComfyModelDef(
|
||||
model.name,
|
||||
this.directory,
|
||||
@@ -212,22 +280,33 @@ export const useModelStore = defineStore('models', () => {
|
||||
: (folder) => api.getModels(folder)
|
||||
}
|
||||
|
||||
let modelFoldersRequestId = 0
|
||||
|
||||
/**
|
||||
* Loads the model folders from the server
|
||||
* Loads the model folders from the server.
|
||||
*
|
||||
* The folder list (and its registration order) always comes from
|
||||
* `/experiment/models`, the source of truth for which model folders exist;
|
||||
* only the per-folder contents differ between the asset API and legacy paths.
|
||||
* Concurrent loads (manual refresh racing the scan-complete reload) commit
|
||||
* only the newest request so a slow stale response cannot overwrite a
|
||||
* fresher folder structure.
|
||||
*/
|
||||
async function loadModelFolders() {
|
||||
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
|
||||
const resData = useAssetAPI
|
||||
? await assetService.getAssetModelFolders()
|
||||
: await api.getModelFolders()
|
||||
const requestId = ++modelFoldersRequestId
|
||||
const resData = await api.getModelFolders()
|
||||
if (requestId !== modelFoldersRequestId) return
|
||||
modelFolderNames.value = resData.map((folder) => folder.name)
|
||||
modelFolderByName.value = {}
|
||||
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const getModelsFunc = createGetModelsFunc()
|
||||
for (const folderName of modelFolderNames.value) {
|
||||
modelFolderByName.value[folderName] = new ModelFolder(
|
||||
folderName,
|
||||
getModelsFunc
|
||||
for (const folder of resData) {
|
||||
modelFolderByName.value[folder.name] = new ModelFolder(
|
||||
folder.name,
|
||||
getModelsFunc,
|
||||
// Display filtering applies to the asset walk only; the legacy
|
||||
// listing keeps its historical server-side (global-set) filtering.
|
||||
useAssetAPI ? effectiveModelExtensions(folder.extensions) : []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -240,9 +319,15 @@ export const useModelStore = defineStore('models', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all model folders' contents from the server
|
||||
* Loads all model folders' contents from the server. Loads the folder
|
||||
* structure first when it has not arrived yet — eager loading can run
|
||||
* before app boot's own loadModelFolders call resolves, and iterating an
|
||||
* empty folder list would silently load nothing.
|
||||
*/
|
||||
async function loadModels() {
|
||||
if (modelFolderNames.value.length === 0) {
|
||||
await loadModelFolders()
|
||||
}
|
||||
return Promise.all(modelFolders.value.map((folder) => folder.load()))
|
||||
}
|
||||
|
||||
@@ -253,25 +338,45 @@ export const useModelStore = defineStore('models', () => {
|
||||
* a newly-introduced folder type is picked up without dropping other
|
||||
* folders' loaded contents.
|
||||
*/
|
||||
const folderRefreshIds = new Map<string, number>()
|
||||
|
||||
async function refreshModelFolder(folderName: string) {
|
||||
assetService.invalidateModelBuckets()
|
||||
if (!(folderName in modelFolderByName.value)) {
|
||||
await refresh()
|
||||
return
|
||||
}
|
||||
const folder = new ModelFolder(folderName, createGetModelsFunc())
|
||||
const requestId = modelFoldersRequestId
|
||||
const refreshId = (folderRefreshIds.get(folderName) ?? 0) + 1
|
||||
folderRefreshIds.set(folderName, refreshId)
|
||||
const folder = new ModelFolder(
|
||||
folderName,
|
||||
createGetModelsFunc(),
|
||||
modelFolderByName.value[folderName].extensions
|
||||
)
|
||||
await folder.load()
|
||||
// A full reload may have rebuilt the folder structure while this folder
|
||||
// refreshed, and a newer refresh of the same folder may have already
|
||||
// committed; committing then would resurrect a stale folder object.
|
||||
if (requestId !== modelFoldersRequestId) return
|
||||
if (folderRefreshIds.get(folderName) !== refreshId) return
|
||||
modelFolderByName.value[folderName] = folder
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the folder structure and re-loads any folder whose contents
|
||||
* had previously been loaded. Used by manual refresh actions ("r" key,
|
||||
* sidebar refresh button) to pick up on-disk changes without losing the
|
||||
* currently-visible contents.
|
||||
* Re-fetches the folder structure and re-loads any folder whose contents
|
||||
* had previously been loaded, picking up server-side changes without
|
||||
* losing the currently-visible contents.
|
||||
*/
|
||||
async function refresh() {
|
||||
async function reloadModels() {
|
||||
assetService.invalidateModelBuckets()
|
||||
// Loading counts as previously loaded: a scan-complete reload can land
|
||||
// while the eager load is still in flight, and replacing those folder
|
||||
// objects without re-loading them would strand the sidebar on
|
||||
// uninitialized folders whose original loads finish into detached
|
||||
// objects.
|
||||
const previouslyLoaded = modelFolders.value
|
||||
.filter((folder) => folder.state === ResourceState.Loaded)
|
||||
.filter((folder) => folder.state !== ResourceState.Uninitialized)
|
||||
.map((folder) => folder.directory)
|
||||
await loadModelFolders()
|
||||
await Promise.all(
|
||||
@@ -281,6 +386,43 @@ export const useModelStore = defineStore('models', () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the backend to rescan the model roots so files added on disk since
|
||||
* startup become assets. Skipped on Cloud (models are ingested via uploads,
|
||||
* not scanned from disk) and on the legacy listing path (which reads the
|
||||
* filesystem live on every request).
|
||||
*/
|
||||
async function requestModelScan() {
|
||||
if (isCloud) return
|
||||
if (!settingStore.get('Comfy.Assets.UseAssetAPI')) return
|
||||
try {
|
||||
await assetService.seedModelAssets()
|
||||
} catch (error) {
|
||||
console.warn('Unable to start model asset scan', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual refresh ("r" key, sidebar refresh button): kicks off a backend
|
||||
* rescan and immediately re-loads the currently known server state; the
|
||||
* scan completion subscription below re-loads again with whatever the
|
||||
* scan discovered. The scan is deliberately not awaited so it runs
|
||||
* concurrently with the reload.
|
||||
*/
|
||||
async function refresh() {
|
||||
void requestModelScan()
|
||||
await reloadModels()
|
||||
}
|
||||
|
||||
const unsubscribeModelsScanned = assetService.onModelsScanned(async () => {
|
||||
try {
|
||||
await reloadModels()
|
||||
} catch (error) {
|
||||
console.error('Failed to reload the model library after a scan', error)
|
||||
}
|
||||
})
|
||||
onScopeDispose(unsubscribeModelsScanned)
|
||||
|
||||
return {
|
||||
models,
|
||||
modelFolders,
|
||||
|
||||
@@ -5,12 +5,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } =
|
||||
vi.hoisted(() => ({
|
||||
const {
|
||||
mockGetSetting,
|
||||
mockRegisterCommand,
|
||||
mockRegisterCommands,
|
||||
mockBrowseModelAssets,
|
||||
registeredCommands,
|
||||
commandStoreCommands
|
||||
} = vi.hoisted(() => {
|
||||
const registeredCommands: { id: string; function: () => unknown }[] = []
|
||||
return {
|
||||
mockGetSetting: vi.fn(),
|
||||
mockRegisterCommand: vi.fn(),
|
||||
mockRegisterCommands: vi.fn()
|
||||
}))
|
||||
mockRegisterCommand: vi.fn((command) => registeredCommands.push(command)),
|
||||
mockRegisterCommands: vi.fn(),
|
||||
mockBrowseModelAssets: vi.fn(),
|
||||
registeredCommands,
|
||||
commandStoreCommands: [] as { id: string; function: () => unknown }[]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
@@ -21,7 +33,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
registerCommand: mockRegisterCommand,
|
||||
commands: []
|
||||
commands: commandStoreCommands
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -99,8 +111,18 @@ describe('useSidebarTabStore', () => {
|
||||
mockGetSetting.mockReset()
|
||||
mockRegisterCommand.mockClear()
|
||||
mockRegisterCommands.mockClear()
|
||||
mockBrowseModelAssets.mockClear()
|
||||
registeredCommands.length = 0
|
||||
commandStoreCommands.length = 0
|
||||
})
|
||||
|
||||
const toggleModelLibrary = async () => {
|
||||
const toggleCommand = registeredCommands.find(
|
||||
(command) => command.id === 'Workspace.ToggleSidebarTab.model-library'
|
||||
)
|
||||
await toggleCommand?.function()
|
||||
}
|
||||
|
||||
it('registers the job history tab when QPO V2 is enabled', () => {
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
@@ -160,4 +182,63 @@ describe('useSidebarTabStore', () => {
|
||||
])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
describe('model library view selection', () => {
|
||||
it('toggles the sidebar tab when the asset view is disabled', async () => {
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.ModelLibrary.UseAssetBrowser' ? false : undefined
|
||||
)
|
||||
commandStoreCommands.push({
|
||||
id: 'Comfy.BrowseModelAssets',
|
||||
function: mockBrowseModelAssets
|
||||
})
|
||||
|
||||
const store = useSidebarTabStore()
|
||||
store.registerCoreSidebarTabs()
|
||||
|
||||
await toggleModelLibrary()
|
||||
|
||||
expect(store.activeSidebarTabId).toBe('model-library')
|
||||
expect(mockBrowseModelAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the asset browser when the browser and asset API are enabled', async () => {
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.ModelLibrary.UseAssetBrowser' ||
|
||||
key === 'Comfy.Assets.UseAssetAPI'
|
||||
? true
|
||||
: undefined
|
||||
)
|
||||
commandStoreCommands.push({
|
||||
id: 'Comfy.BrowseModelAssets',
|
||||
function: mockBrowseModelAssets
|
||||
})
|
||||
|
||||
const store = useSidebarTabStore()
|
||||
store.registerCoreSidebarTabs()
|
||||
|
||||
await toggleModelLibrary()
|
||||
|
||||
expect(mockBrowseModelAssets).toHaveBeenCalledOnce()
|
||||
expect(store.activeSidebarTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the sidebar tree when the asset API is disabled', async () => {
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.ModelLibrary.UseAssetBrowser' ? true : false
|
||||
)
|
||||
commandStoreCommands.push({
|
||||
id: 'Comfy.BrowseModelAssets',
|
||||
function: mockBrowseModelAssets
|
||||
})
|
||||
|
||||
const store = useSidebarTabStore()
|
||||
store.registerCoreSidebarTabs()
|
||||
|
||||
await toggleModelLibrary()
|
||||
|
||||
expect(store.activeSidebarTabId).toBe('model-library')
|
||||
expect(mockBrowseModelAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,8 +76,13 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
// The asset browser cannot function without the asset API, so the
|
||||
// browser routing derives from both settings: with the API disabled
|
||||
// the browser setting is inert and the tab always opens the sidebar
|
||||
// tree, rather than prompt-correcting the combination.
|
||||
if (
|
||||
tab.id === 'model-library' &&
|
||||
settingStore.get('Comfy.ModelLibrary.UseAssetBrowser') &&
|
||||
settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
) {
|
||||
await commandStore.commands
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Keybinding } from '@/platform/keybindings/types'
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
@@ -265,5 +267,9 @@ export interface ComfyExtension {
|
||||
*/
|
||||
onAuthUserLogout?(): Promise<void> | void
|
||||
|
||||
onNodeOutputsUpdated?(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
): void
|
||||
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user