Compare commits

..

5 Commits

Author SHA1 Message Date
huang47
dd24e83ef1 feat(workspace): prototype partner-node discovery controls 2026-07-10 15:24:50 -07:00
huang47
08461c6c49 feat(workspace): prototype partner-node toggle with client-side enforcement
Workspace-level enable/disable for partner (API) nodes (DES-484).
Disabled nodes are hidden from search/library/context menu, blocked
from insertion, filtered out of the template gallery, and block
workflow execution with an explanatory dialog. State is
localStorage-backed pending the workspace backend endpoints.
2026-07-10 12:37:28 -07:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
68 changed files with 2456 additions and 3734 deletions

View File

@@ -254,18 +254,6 @@ 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'

View File

@@ -1,13 +1,4 @@
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 } {
@@ -98,70 +89,6 @@ 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',

View File

@@ -3,7 +3,6 @@ import type { Page, Route } from '@playwright/test'
import type {
Asset,
ListAssetsResponse,
SeedAssetsResponse,
UpdateAssetData
} from '@comfyorg/ingest-types'
import {
@@ -36,13 +35,6 @@ 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) {
@@ -149,8 +141,6 @@ 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'))
@@ -159,7 +149,7 @@ export class AssetHelper {
return route.fallback()
}
const pattern = ASSET_API_ROUTE_PATTERN
const pattern = '**/assets**'
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
@@ -175,7 +165,7 @@ export class AssetHelper {
})
}
const pattern = ASSET_API_ROUTE_PATTERN
const pattern = '**/assets**'
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
@@ -286,11 +276,6 @@ 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,

View File

@@ -51,22 +51,6 @@ 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.
*/

View File

@@ -1,20 +0,0 @@
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)
}

View File

@@ -1,231 +0,0 @@
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()
})
})

View File

@@ -1,73 +0,0 @@
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()
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.48.0",
"version": "1.48.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -12,6 +12,7 @@ import { computed, onMounted, watch } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { installPartnerNodePrototype } from '@/platform/workspace/partnerNodePrototype/installPartnerNodePrototype'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -21,6 +22,8 @@ import { useConflictDetection } from '@/workbench/extensions/manager/composables
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
installPartnerNodePrototype()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)

View File

@@ -426,7 +426,7 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay', 'p-toast'])(
it.for(['p-dialog', 'p-select-overlay'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')

View File

@@ -1,13 +1,10 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// 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.
// 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.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog, .p-toast'
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
// 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

View File

@@ -1,6 +1,5 @@
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'
@@ -19,10 +18,7 @@ const {
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
mockLoadModels,
downloadStoreState,
settingState,
modelsState
downloadStoreState
} = vi.hoisted(() => {
let capturedRoot: TreeExplorerNode<unknown> | null = null
return {
@@ -37,13 +33,7 @@ const {
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
mockLoadModels: vi.fn().mockResolvedValue([]),
downloadStoreState: { setLastCompleted: (_: unknown) => {} },
settingState: { useAssetAPI: false, autoLoadAll: false },
modelsState: {
push: (_: unknown) => {},
reset: () => {}
}
downloadStoreState: { setLastCompleted: (_: unknown) => {} }
}
})
@@ -64,30 +54,20 @@ const mockModel = fromPartial<ComfyModelDef>({
searchable: 'checkpoints/model.safetensors'
})
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/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/assetDownloadStore', async () => {
const { ref } = await import('vue')
@@ -112,10 +92,6 @@ 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
})
})
@@ -128,45 +104,26 @@ vi.mock('@/composables/useTreeExpansion', () => ({
})
}))
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/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/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchInput',
template: '<input data-testid="search-input" @input="onInput" />',
template: '<input data-testid="search-input" />',
props: ['modelValue', 'placeholder'],
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)
}
}
}
setup() {
return { focus: vi.fn() }
},
expose: ['focus']
}
}))
@@ -177,8 +134,7 @@ vi.mock('./SidebarTopArea.vue', () => ({
vi.mock('./SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
template: '<div><slot name="header" /><slot name="body" /></div>'
}
}))
@@ -201,17 +157,13 @@ 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 },
directives: { tooltip: {} }
stubs: { teleport: true }
}
})
}
@@ -284,67 +236,4 @@ 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)
})
})
})

View File

@@ -11,7 +11,6 @@
<i class="icon-[lucide--refresh-cw] size-4" />
</Button>
<Button
v-if="!usesAssetAPI"
v-tooltip.bottom="$t('g.loadAllFolders')"
variant="muted-textonly"
size="icon"
@@ -78,28 +77,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 = computed<ComfyModelDef[]>(() => {
const search = searchQuery.value.toLocaleLowerCase()
if (!search) return []
return modelStore.models.filter((model) => model.searchable.includes(search))
})
const filteredModels = ref<ComfyModelDef[]>([])
const handleSearch = async (query: string) => {
if (!query) {
filteredModels.value = []
expandedKeys.value = {}
return
}
// Load all models to ensure results cover folders not yet opened
// Load all models to ensure we have the latest data
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
@@ -113,12 +112,6 @@ 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> => {
@@ -200,13 +193,7 @@ watch(
onMounted(async () => {
searchBoxRef.value?.focus()
// 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')
) {
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
await modelStore.loadModels()
}
})

View File

@@ -26,7 +26,6 @@ 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'
@@ -38,7 +37,17 @@ 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(() => getModelPreviewUrl(modelDef.value))
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 previewRef = ref<InstanceType<typeof ModelPreview> | null>(null)
const modelPreviewStyle = ref<CSSProperties>({

View File

@@ -33,8 +33,7 @@ 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',
SUPPORTS_MODEL_TYPE_TAGS = 'supports_model_type_tags'
SIGNUP_TURNSTILE = 'signup_turnstile'
}
/**
@@ -209,12 +208,6 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get supportsModelTypeTags() {
return api.getServerFeature(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
false
)
}
})

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "يرجى إبقاء إجابتك أقل من {max} حرفًا.",
"chooseAnOption": "يرجى اختيار خيار.",
"describeAnswer": "يرجى وصف إجابتك.",
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
},
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
"options": {
"familiarity": {
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
"basics": "مرتاح مع الأساسيات",
"expert": "خبير (أساعد الآخرين)",
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
"experience": {
"new": "جديد على ComfyUI",
"pro": "أنا مستخدم محترف",
"some": "لدي معرفة جيدة"
},
"focus": {
"custom_nodes": "عُقد مخصصة",
"pipelines": "مسارات مؤتمتة",
"products": "منتجات للآخرين"
},
"intent": {
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
"api": "نقاط نهاية API لتشغيل مسارات العمل",
"apps": "تطبيقات مبسطة من مسارات العمل",
"audio": "صوت / موسيقى",
"custom_nodes": "عُقد مخصصة",
"apps_api": "تطبيقات وواجهات برمجة التطبيقات",
"exploring": "أستكشف فقط",
"images": "صور",
"not_sure": "لست متأكداً",
"videos": "فيديوهات",
"other": "شيء آخر",
"otherPlaceholder": "ماذا تريد أن تصنع؟",
"video": "فيديو",
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
},
"source": {
"conference": ؤتمر أو فعالية",
"discord": "ديسكورد / مجتمع",
"community": جتمع أو منتدى",
"friend": "صديق أو زميل",
"github": "GitHub",
"other": "أخرى",
"otherPlaceholder": "من أين وجدتنا؟",
"search": "جوجل / بحث",
"social": "وسائل التواصل الاجتماعي"
},
"source_social": {
"discord": "ديسكورد",
"instagram": "إنستغرام",
"linkedin": "لينكدإن",
"newsletter": "النشرة البريدية أو مدونة",
"other": "أخرى",
"reddit": "ريديت",
"search": "جوجل / بحث",
"twitter": "تويتر / X",
"tiktok": "تيك توك",
"twitter": "X (تويتر)",
"youtube": "يوتيوب"
},
"usage": {
"education": "تعليمي (طالب أو معلم)",
"personal": "استخدام شخصي",
"work": "عمل"
}
},
"otherPlaceholder": "أخبرنا المزيد",
"placeholder": "نص بديل لأسئلة الاستبيان",
"steps": {
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"source": "من أين سمعت عن ComfyUI؟",
"usage": "كيف تخطط لاستخدام ComfyUI؟"
},
"title": "استبيان السحابة"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "تعرف على السحابة",
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_experience": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_focus": "ماذا تبني؟",
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
"cloudSurvey_steps_source_social": "أي منصة؟",
"cloudWaitlist_contactLink": "هنا",
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
"color": {

View File

@@ -1566,6 +1566,7 @@
"Workspace": "Workspace",
"Error System": "Error System",
"Other": "Other",
"PartnerNodes": "Partner Nodes Prototype",
"Secrets": "Secrets",
"Node Library": "Node Library",
"PointCloud": "Point Cloud"
@@ -2414,6 +2415,16 @@
"tooltipLearnMore": "Learn more..."
}
},
"desktopLogin": {
"confirmSummary": "Approve desktop sign-in?",
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
"successSummary": "Signed in",
"successDetail": "You can return to the ComfyUI desktop app.",
"expiredSummary": "Desktop sign-in failed",
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
"failedSummary": "Desktop sign-in failed",
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",
@@ -2844,6 +2855,20 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"partnerNodes": {
"title": "Partner Nodes Prototype",
"description": "Preview which partner nodes appear in the modern node library and search.",
"prototypeTitle": "Local UX prototype",
"prototypeDescription": "Changes are stored only in this browser for the active workspace. They do not enforce node insertion, templates, or backend execution.",
"searchPlaceholder": "Search partner nodes",
"enableFiltered": "Enable filtered",
"disableFiltered": "Disable filtered",
"enabledCount": "{enabled} of {total} enabled",
"empty": "No partner nodes are available on this server.",
"noResults": "No partner nodes match this search.",
"toggleLabel": "Show {node} in modern node discovery",
"defaultDeny": "Newly discovered partner nodes start disabled in this prototype."
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
@@ -4383,7 +4408,9 @@
"hideDevOnly": "Hide Dev-Only Nodes",
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search",
"hidePrototypeDisabledPartnerNodes": "Hide Prototype-Disabled Partner Nodes",
"hidePrototypeDisabledPartnerNodesDescription": "Hides partner nodes disabled in the local UX prototype from modern node discovery"
},
"secrets": {
"title": "API Keys & Secrets",

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantén tu respuesta por debajo de {max} caracteres.",
"chooseAnOption": "Por favor, elige una opción.",
"describeAnswer": "Por favor, describe tu respuesta.",
"selectAtLeastOne": "Por favor, selecciona al menos una opción."
},
"intro": "Ayúdanos a personalizar tu experiencia con ComfyUI.",
"options": {
"familiarity": {
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
"basics": "Cómodo con lo básico",
"expert": "Experto (ayudo a otros)",
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
"starting": "Recién comenzando (siguiendo tutoriales)"
"experience": {
"new": "Nuevo en ComfyUI",
"pro": "Soy usuario avanzado",
"some": "Ya tengo experiencia"
},
"focus": {
"custom_nodes": "Nodos personalizados",
"pipelines": "Pipelines automatizados",
"products": "Productos para otros"
},
"intent": {
"3d_game": "Recursos 3D / recursos para juegos",
"api": "Endpoints de API para ejecutar flujos de trabajo",
"apps": "Apps simplificadas a partir de flujos de trabajo",
"audio": "Audio / música",
"custom_nodes": "Nodos personalizados",
"apps_api": "Aplicaciones y APIs",
"exploring": "Solo explorando",
"images": "Imágenes",
"not_sure": "No estoy seguro",
"videos": "Videos",
"other": "Otra cosa",
"otherPlaceholder": "¿Qué quieres crear?",
"video": "Video",
"workflows": "Flujos de trabajo o pipelines personalizados"
},
"source": {
"conference": "Conferencia o evento",
"discord": "Discord / comunidad",
"community": "Una comunidad o foro",
"friend": "Amigo o colega",
"github": "GitHub",
"other": "Otro",
"otherPlaceholder": "¿Dónde nos encontraste?",
"search": "Google / búsqueda",
"social": "Redes sociales"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter o blog",
"other": "Otro",
"reddit": "Reddit",
"search": "Google / búsqueda",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Educación (estudiante o docente)",
"personal": "Uso personal",
"work": "Trabajo"
}
},
"otherPlaceholder": "Cuéntanos más",
"placeholder": "Marcador de posición para preguntas de la encuesta",
"steps": {
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"intent": "¿Qué quieres crear con ComfyUI?",
"source": "¿Dónde escuchaste sobre ComfyUI?",
"usage": "¿Cómo planeas usar ComfyUI?"
},
"title": "Encuesta en la Nube"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
"cloudStart_title": "comienza a crear en segundos",
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
"cloudWaitlist_contactLink": "aquí",
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "لطفاً پاسخ خود را کمتر از {max} نویسه نگه دارید.",
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
},
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را متناسب‌سازی کنیم.",
"options": {
"familiarity": {
"advanced": "کاربر پیشرفته (جریان‌کارهای سفارشی)",
"basics": "آشنایی با مبانی",
"expert": "کاربر خبره (به دیگران کمک می‌کنم)",
"new": "جدید در ComfyUI (تا کنون استفاده نکرده‌ام)",
"starting": "تازه شروع کرده‌ام (در حال دنبال کردن آموزش‌ها)"
"experience": {
"new": "جدید در ComfyUI",
"pro": "کاربر حرفه‌ای هستم",
"some": "آشنایی نسبی دارم"
},
"focus": {
"custom_nodes": "Nodeهای سفارشی",
"pipelines": "پایپ‌لاین‌های خودکار",
"products": "محصولات برای دیگران"
},
"intent": {
"3d_game": "دارایی سه‌بعدی / دارایی بازی",
"api": "API endpoint برای اجرای workflow",
"apps": "اپلیکیشن ساده‌شده از workflow",
"audio": "صدا / موسیقی",
"custom_nodes": "node سفارشی",
"apps_api": "اپلیکیشن‌ها و APIها",
"exploring": "فقط در حال بررسی",
"images": "تصویر",
"not_sure": "مطمئن نیستم",
"videos": "ویدیو",
"other": "چیز دیگری",
"otherPlaceholder": "چه چیزی می‌خواهید بسازید؟",
"video": "ویدیو",
"workflows": "workflow یا pipeline سفارشی"
},
"source": {
"conference": "کنفرانس یا رویداد",
"discord": "Discord / انجمن",
"community": "انجمن یا فروم",
"friend": "دوست یا همکار",
"github": "GitHub",
"other": "سایر",
"otherPlaceholder": "از کجا با ما آشنا شدید؟",
"search": "Google / جستجو",
"social": "رسانه‌های اجتماعی"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "خبرنامه یا وبلاگ",
"other": "سایر",
"reddit": "Reddit",
"search": "Google / جستجو",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "آموزشی (دانشجو یا مدرس)",
"personal": "استفاده شخصی",
"work": "کاری"
}
},
"otherPlaceholder": "بیشتر توضیح دهید",
"placeholder": "جای‌نگهدار سوالات نظرسنجی",
"steps": {
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
"intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"source": "از کجا با ComfyUI آشنا شدید؟",
"usage": "برنامه شما برای استفاده از ComfyUI چیست؟"
},
"title": "نظرسنجی ابری"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "درباره Cloud بیشتر بدانید",
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_experience": "تا چه حد با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_focus": "در حال ساخت چه چیزی هستید؟",
"cloudSurvey_steps_intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
"cloudSurvey_steps_usage": "برنامه شما برای استفاده از ComfyUI چیست؟",
"cloudSurvey_steps_source_social": "کدام پلتفرم؟",
"cloudWaitlist_contactLink": "اینجا",
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Veuillez limiter votre réponse à {max} caractères.",
"chooseAnOption": "Veuillez choisir une option.",
"describeAnswer": "Veuillez décrire votre réponse.",
"selectAtLeastOne": "Veuillez sélectionner au moins une option."
},
"intro": "Aidez-nous à personnaliser votre expérience ComfyUI.",
"options": {
"familiarity": {
"advanced": "Utilisateur avancé (workflows personnalisés)",
"basics": "À l'aise avec les bases",
"expert": "Expert (j'aide les autres)",
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
"starting": "Je débute (je suis des tutoriels)"
"experience": {
"new": "Nouveau sur ComfyUI",
"pro": "Utilisateur avancé",
"some": "Je me débrouille"
},
"focus": {
"custom_nodes": "Nœuds personnalisés",
"pipelines": "Pipelines automatisés",
"products": "Produits pour les autres"
},
"intent": {
"3d_game": "Assets 3D / assets de jeu",
"api": "Points de terminaison API pour exécuter des workflows",
"apps": "Applications simplifiées à partir de workflows",
"audio": "Audio / musique",
"custom_nodes": "Nœuds personnalisés",
"apps_api": "Applications et API",
"exploring": "Je découvre simplement",
"images": "Images",
"not_sure": "Pas sûr",
"videos": "Vidéos",
"other": "Autre chose",
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
"video": "Vidéo",
"workflows": "Workflows ou pipelines personnalisés"
},
"source": {
"conference": "Conférence ou événement",
"discord": "Discord / communauté",
"community": "Une communauté ou un forum",
"friend": "Ami ou collègue",
"github": "GitHub",
"other": "Autre",
"otherPlaceholder": "Où nous avez-vous trouvés ?",
"search": "Google / recherche",
"social": "Réseaux sociaux"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Autre",
"reddit": "Reddit",
"search": "Google / recherche",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Éducation (étudiant ou enseignant)",
"personal": "Usage personnel",
"work": "Travail"
}
},
"otherPlaceholder": "Dites-nous en plus",
"placeholder": "Texte indicatif des questions de l'enquête",
"steps": {
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
"source": "Où avez-vous entendu parler de ComfyUI ?",
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
},
"title": "Enquête Cloud"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
"cloudStart_title": "créez en quelques secondes",
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
"cloudWaitlist_contactLink": "ici",
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "אנא שמרו את התשובה שלכם עד {max} תווים.",
"chooseAnOption": "אנא בחר אפשרות.",
"describeAnswer": "אנא תאר את תשובתך.",
"selectAtLeastOne": "אנא בחר לפחות אפשרות אחת."
},
"intro": "עזרו לנו להתאים את חוויית ה-ComfyUI שלך.",
"options": {
"familiarity": {
"advanced": "מתקדם — בונה ועורך תהליכי עבודה",
"basics": "בינוני — מרגיש בנוח עם היסודות",
"expert": ומחה — אני עוזר לאחרים",
"new": "חדש — מעולם לא השתמשתי",
"starting": "מתחיל — עוקב אחר מדריכים"
"experience": {
"new": "חדש/ה ב-ComfyUI",
"pro": "משתמש/ת מתקדם/ת",
"some": כיר/ה את המערכת"
},
"focus": {
"custom_nodes": "צמתים מותאמים אישית",
"pipelines": "צינורות עבודה אוטומטיים",
"products": "מוצרים לאחרים"
},
"intent": {
"3d_game": "נכסי תלת-ממד / נכסי משחקים",
"api": "נקודות קצה של API להרצת תהליכי עבודה",
"apps": "יישומים מפושטים מתהליכי עבודה",
"audio": "שמע / מוזיקה",
"custom_nodes": "צמתים מותאמים",
"apps_api": "אפליקציות ו-API",
"exploring": "רק בודק/ת",
"images": "תמונות",
"not_sure": "לא בטוח",
"videos": "סרטונים",
"other": "משהו אחר",
"otherPlaceholder": "מה תרצו ליצור?",
"video": "וידאו",
"workflows": "תהליכי עבודה או צינורות (pipelines) מותאמים"
},
"source": {
"conference": "כנס או אירוע",
"discord": "Discord / קהילה",
"community": "קהילה או פורום",
"friend": "חבר או עמית",
"github": "GitHub",
"other": "אחר",
"otherPlaceholder": "היכן שמעתם עלינו?",
"search": "Google / חיפוש",
"social": "רשתות חברתיות"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ניוזלטר או בלוג",
"other": "אחר",
"reddit": "Reddit",
"search": "Google / חיפוש",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "חינוך (סטודנט או מרצה)",
"personal": "שימוש אישי",
"work": "עבודה"
}
},
"otherPlaceholder": "ספרו לנו עוד",
"placeholder": "מציין מיקום לשאלות הסקר",
"steps": {
"familiarity": "עד כמה אתה מכיר את ComfyUI?",
"intent": "מה ברצונך ליצור עם ComfyUI?",
"source": "היכן שמעת על ComfyUI?",
"usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?"
},
"title": "סקר ענן"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "למד על הענן",
"cloudStart_title": "התחל ליצור תוך שניות",
"cloudStart_wantToRun": "מעדיף להריץ את ComfyUI מקומית?",
"cloudSurvey_steps_familiarity": "עד כמה אתה מכיר את ComfyUI?",
"cloudSurvey_steps_experience": "עד כמה אתם מכירים את ComfyUI?",
"cloudSurvey_steps_focus": "מה אתם בונים?",
"cloudSurvey_steps_intent": "מה ברצונך ליצור עם ComfyUI?",
"cloudSurvey_steps_source": "היכן שמעת על ComfyUI?",
"cloudSurvey_steps_usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?",
"cloudSurvey_steps_source_social": "באיזו פלטפורמה?",
"cloudWaitlist_contactLink": "כאן",
"cloudWaitlist_questionsText": "שאלות? צור איתנו קשר",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "回答は{max}文字以内で入力してください。",
"chooseAnOption": "オプションを選択してください。",
"describeAnswer": "回答を記述してください。",
"selectAtLeastOne": "少なくとも1つ選択してください。"
},
"intro": "ComfyUIの体験をより最適化するためにご協力ください。",
"options": {
"familiarity": {
"advanced": "上級ユーザー(カスタムワークフロー)",
"basics": "基本操作に慣れている",
"expert": "エキスパート(他者を支援)",
"new": "ComfyUI初心者使用経験なし",
"starting": "使い始め(チュートリアルをフォロー中)"
"experience": {
"new": "ComfyUIは初めて",
"pro": "上級ユーザー",
"some": "ある程度使い方が分かる"
},
"focus": {
"custom_nodes": "カスタムノード",
"pipelines": "自動パイプライン",
"products": "他者向けプロダクト"
},
"intent": {
"3d_game": "3Dアセットゲームアセット",
"api": "ワークフロー実行用APIエンドポイント",
"apps": "ワークフローから簡易アプリ作成",
"audio": "音声/音楽",
"custom_nodes": "カスタムノード",
"apps_api": "アプリ・API",
"exploring": "探索中",
"images": "画像",
"not_sure": "まだ分からない",
"videos": "動画",
"other": "その他",
"otherPlaceholder": "何を作りたいですか?",
"video": "動画",
"workflows": "カスタムワークフローやパイプライン"
},
"source": {
"conference": "カンファレンスやイベント",
"discord": "Discordコミュニティ",
"community": "コミュニティ・フォーラム",
"friend": "友人または同僚",
"github": "GitHub",
"other": "その他",
"otherPlaceholder": "どこで私たちを知りましたか?",
"search": "Google検索",
"social": "ソーシャルメディア"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ニュースレターやブログ",
"other": "その他",
"reddit": "Reddit",
"search": "Google検索",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "XTwitter",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生または教育者)",
"personal": "個人利用",
"work": "仕事"
}
},
"otherPlaceholder": "詳細をお聞かせください",
"placeholder": "アンケート質問のプレースホルダー",
"steps": {
"familiarity": "ComfyUIの使用経験はどの程度ですか",
"intent": "ComfyUIで何を作成したいですか",
"source": "ComfyUIをどこで知りましたか",
"usage": "ComfyUIをどのように利用する予定ですか"
},
"title": "クラウドアンケート"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "クラウドについて学ぶ",
"cloudStart_title": "数秒で作成を開始",
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか",
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか",
"cloudSurvey_steps_experience": "ComfyUIの知識レベルは",
"cloudSurvey_steps_focus": "何を作成していますか?",
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか",
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか",
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
"cloudSurvey_steps_source_social": "どのプラットフォームですか?",
"cloudWaitlist_contactLink": "こちら",
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "답변은 {max}자 이내로 작성해 주세요.",
"chooseAnOption": "옵션을 선택해 주세요.",
"describeAnswer": "답변을 설명해 주세요.",
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
},
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
"options": {
"familiarity": {
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
"basics": "기본 기능에 익숙함",
"expert": "전문가 (다른 사용자 도움)",
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
"experience": {
"new": "ComfyUI가 처음이에요",
"pro": "전문 사용자입니다",
"some": "기본적인 사용법을 알아요"
},
"focus": {
"custom_nodes": "커스텀 노드",
"pipelines": "자동화 파이프라인",
"products": "타인을 위한 제품"
},
"intent": {
"3d_game": "3D 에셋 / 게임 에셋",
"api": "워크플로우 실행용 API 엔드포인트",
"apps": "워크플로우 기반 간소화 앱",
"audio": "오디오 / 음악",
"custom_nodes": "커스텀 노드",
"apps_api": "앱 및 API",
"exploring": "그냥 둘러보는 중",
"images": "이미지",
"not_sure": "잘 모르겠음",
"videos": "비디오",
"other": "기타",
"otherPlaceholder": "무엇을 만들고 싶으신가요?",
"video": "비디오",
"workflows": "맞춤형 워크플로우 또는 파이프라인"
},
"source": {
"conference": "컨퍼런스 또는 이벤트",
"discord": "Discord / 커뮤니티",
"community": "커뮤니티 또는 포럼",
"friend": "친구 또는 동료",
"github": "GitHub",
"other": "기타",
"otherPlaceholder": "어디서 저희를 알게 되셨나요?",
"search": "Google / 검색",
"social": "소셜 미디어"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "뉴스레터 또는 블로그",
"other": "기타",
"reddit": "Reddit",
"search": "Google / 검색",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "교육용(학생 또는 교육자)",
"personal": "개인용",
"work": "업무용"
}
},
"otherPlaceholder": "자세히 알려주세요",
"placeholder": "설문 질문 자리표시자",
"steps": {
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"source": "ComfyUI를 어디에서 알게 되셨나요?",
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
},
"title": "클라우드 설문"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "클라우드 알아보기",
"cloudStart_title": "몇 초 만에 제작 시작",
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
"cloudSurvey_steps_familiarity": "ComfyUI 얼마나 익숙하신가요?",
"cloudSurvey_steps_experience": "ComfyUI 얼마나 잘 알고 계신가요?",
"cloudSurvey_steps_focus": "무엇을 만들고 계신가요?",
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"cloudSurvey_steps_source": "ComfyUI를 어디에서 알게 되셨나요?",
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
"cloudSurvey_steps_source_social": "어떤 플랫폼에서 알게 되셨나요?",
"cloudWaitlist_contactLink": "여기",
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantenha sua resposta com menos de {max} caracteres.",
"chooseAnOption": "Por favor, escolha uma opção.",
"describeAnswer": "Por favor, descreva sua resposta.",
"selectAtLeastOne": "Por favor, selecione pelo menos uma opção."
},
"intro": "Ajude-nos a personalizar sua experiência no ComfyUI.",
"options": {
"familiarity": {
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
"basics": "Confortável com o básico",
"expert": "Especialista (ajuda outras pessoas)",
"new": "Novo no ComfyUI (nunca usei antes)",
"starting": "Começando agora (seguindo tutoriais)"
"experience": {
"new": "Novo no ComfyUI",
"pro": "Sou um usuário avançado",
"some": "Já conheço um pouco"
},
"focus": {
"custom_nodes": "Nós personalizados",
"pipelines": "Pipelines automatizados",
"products": "Produtos para outros"
},
"intent": {
"3d_game": "Assets 3D / assets para jogos",
"api": "Endpoints de API para executar workflows",
"apps": "Apps simplificados a partir de workflows",
"audio": "Áudio / música",
"custom_nodes": "Nodes personalizados",
"apps_api": "Apps e APIs",
"exploring": "Só explorando",
"images": "Imagens",
"not_sure": "Não tenho certeza",
"videos": "Vídeos",
"other": "Outra coisa",
"otherPlaceholder": "O que você quer criar?",
"video": "Vídeo",
"workflows": "Workflows ou pipelines personalizados"
},
"source": {
"conference": "Conferência ou evento",
"discord": "Discord / comunidade",
"community": "Uma comunidade ou fórum",
"friend": "Amigo ou colega",
"github": "GitHub",
"other": "Outro",
"otherPlaceholder": "Onde você nos encontrou?",
"search": "Google / busca",
"social": "Mídias sociais"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Outro",
"reddit": "Reddit",
"search": "Google / busca",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Educação (estudante ou educador)",
"personal": "Uso pessoal",
"work": "Trabalho"
}
},
"otherPlaceholder": "Conte-nos mais",
"placeholder": "Espaço reservado para perguntas da pesquisa",
"steps": {
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"intent": "O que você deseja criar com o ComfyUI?",
"source": "Onde você ouviu falar do ComfyUI?",
"usage": "Como você pretende usar o ComfyUI?"
},
"title": "Pesquisa da Nuvem"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
"cloudStart_title": "comece a criar em segundos",
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
"cloudSurvey_steps_focus": "O que você está construindo?",
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
"cloudSurvey_steps_source_social": "Em qual plataforma?",
"cloudWaitlist_contactLink": "aqui",
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Пожалуйста, сократите ваш ответ до {max} символов.",
"chooseAnOption": "Пожалуйста, выберите вариант.",
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
},
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
"options": {
"familiarity": {
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
"basics": "Уверенно владею основами",
"expert": "Эксперт (помогаю другим)",
"new": "Новичок в ComfyUI (никогда не использовал)",
"starting": "Только начинаю (следую руководствам)"
"experience": {
"new": "Впервые в ComfyUI",
"pro": "Я опытный пользователь",
"some": "Я немного знаком(а)"
},
"focus": {
"custom_nodes": "Пользовательские узлы",
"pipelines": "Автоматизированные пайплайны",
"products": "Продукты для других"
},
"intent": {
"3d_game": "3D-ассеты / игровые ассеты",
"api": "API endpoints для запуска workflow",
"apps": "Упрощённые приложения из workflow",
"audio": "Аудио / музыка",
"custom_nodes": "Пользовательские node",
"apps_api": "Приложения и API",
"exploring": "Просто изучаю",
"images": "Изображения",
"not_sure": "Не уверен",
"videos": "Видео",
"other": "Другое",
"otherPlaceholder": "Что вы хотите создать?",
"video": "Видео",
"workflows": "Пользовательские workflow или pipeline"
},
"source": {
"conference": "Конференция или мероприятие",
"discord": "Discord / сообщество",
"community": "Сообщество или форум",
"friend": "Друг или коллега",
"github": "GitHub",
"other": "Другое",
"otherPlaceholder": "Где вы о нас узнали?",
"search": "Google / поиск",
"social": "Социальные сети"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Новостная рассылка или блог",
"other": "Другое",
"reddit": "Reddit",
"search": "Google / поиск",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Образование (студент или преподаватель)",
"personal": "Личное использование",
"work": "Работа"
}
},
"otherPlaceholder": "Расскажите подробнее",
"placeholder": "Вопросы для опроса",
"steps": {
"familiarity": "Насколько вы знакомы с ComfyUI?",
"intent": "Что вы хотите создавать с помощью ComfyUI?",
"source": "Где вы узнали о ComfyUI?",
"usage": "Как вы планируете использовать ComfyUI?"
},
"title": "Облачный опрос"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Узнать о Cloud",
"cloudStart_title": "начать создавать за секунды",
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
"cloudSurvey_steps_experience": "Насколько хорошо вы знаете ComfyUI?",
"cloudSurvey_steps_focus": "Что вы создаёте?",
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
"cloudSurvey_steps_source_social": "На какой платформе?",
"cloudWaitlist_contactLink": "здесь",
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Lütfen cevabınızı {max} karakterin altında tutun.",
"chooseAnOption": "Lütfen bir seçenek seçin.",
"describeAnswer": "Lütfen cevabınızııklayın.",
"selectAtLeastOne": "Lütfen en az bir seçenek seçin."
},
"intro": "ComfyUI deneyiminizi size özel hale getirmemize yardımcı olun.",
"options": {
"familiarity": {
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
"basics": "Temel bilgilerde rahatım",
"expert": "Uzman (başkalarına yardım ediyorum)",
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
"experience": {
"new": "ComfyUI'ye yeni",
"pro": "Güçlü bir kullanıcıyım",
"some": "Biraz biliyorum"
},
"focus": {
"custom_nodes": "Özel node'lar",
"pipelines": "Otomatikleştirilmiş pipeline'lar",
"products": "Başkaları için ürünler"
},
"intent": {
"3d_game": "3D varlıklar / oyun varlıkları",
"api": "İş akışlarını çalıştırmak için API uç noktaları",
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
"audio": "Ses / müzik",
"custom_nodes": "Özel node'lar",
"apps_api": "Uygulamalar ve API'ler",
"exploring": "Sadece keşfediyorum",
"images": "Görseller",
"not_sure": "Emin değilim",
"videos": "Videolar",
"other": "Başka bir şey",
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
"video": "Video",
"workflows": "Özel iş akışları veya boru hatları"
},
"source": {
"conference": "Konferans veya etkinlik",
"discord": "Discord / topluluk",
"community": "Bir topluluk veya forum",
"friend": "Arkadaş veya iş arkadaşı",
"github": "GitHub",
"other": "Diğer",
"otherPlaceholder": "Bizi nereden buldunuz?",
"search": "Google / arama",
"social": "Sosyal medya"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Bülten veya blog",
"other": "Diğer",
"reddit": "Reddit",
"search": "Google / arama",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Eğitim (öğrenci veya eğitmen)",
"personal": "Kişisel kullanım",
"work": "İş"
}
},
"otherPlaceholder": "Daha fazla bilgi verin",
"placeholder": "Anket soruları yer tutucusu",
"steps": {
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"source": "ComfyUI'yi nereden duydunuz?",
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
},
"title": "Bulut Anketi"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Cloud hakkında bilgi edinin",
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
"cloudSurvey_steps_source_social": "Hangi platform?",
"cloudWaitlist_contactLink": "burada",
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "請將您的回答控制在 {max} 個字以內。",
"chooseAnOption": "請選擇一個選項。",
"describeAnswer": "請描述您的答案。",
"selectAtLeastOne": "請至少選擇一個選項。"
},
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
"options": {
"familiarity": {
"advanced": "進階使用者(自訂工作流程)",
"basics": "熟悉基礎操作",
"expert": "專家(協助他人)",
"new": "ComfyUI 新手(從未使用過)",
"starting": "剛開始(正在跟隨教學)"
"experience": {
"new": "ComfyUI 新手",
"pro": "我是進階使用者",
"some": "我已經熟悉操作"
},
"focus": {
"custom_nodes": "自訂節點",
"pipelines": "自動化流程",
"products": "為他人打造產品"
},
"intent": {
"3d_game": "3D 素材/遊戲素材",
"api": "執行工作流程的 API 端點",
"apps": "由工作流程簡化的應用程式",
"audio": "音訊/音樂",
"custom_nodes": "自訂節點",
"apps_api": "應用程式與 API",
"exploring": "只是探索",
"images": "圖像",
"not_sure": "尚未確定",
"videos": "影片",
"other": "其他",
"otherPlaceholder": "您想製作什麼?",
"video": "影片",
"workflows": "自訂工作流程或管線"
},
"source": {
"conference": "研討會或活動",
"discord": "Discord社群",
"community": "社群或論壇",
"friend": "朋友或同事",
"github": "GitHub",
"other": "其他",
"otherPlaceholder": "您是在哪裡發現我們的?",
"search": "Google搜尋引擎",
"social": "社群媒體"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "電子報或部落格",
"other": "其他",
"reddit": "Reddit",
"search": "Google搜尋引擎",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "XTwitter",
"youtube": "YouTube"
},
"usage": {
"education": "教育用途(學生或教育者)",
"personal": "個人用途",
"work": "工作用途"
}
},
"otherPlaceholder": "請告訴我們更多",
"placeholder": "問卷問題佔位符",
"steps": {
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
"intent": "您想用 ComfyUI 創作什麼?",
"source": "您是從哪裡得知 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "雲端問卷"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "了解雲端服務",
"cloudStart_title": "數秒內開始創作",
"cloudStart_wantToRun": "想要在本機運行 ComfyUI",
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何",
"cloudSurvey_steps_experience": "您對 ComfyUI 的熟悉程度?",
"cloudSurvey_steps_focus": "您正在製作什麼?",
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
"cloudSurvey_steps_source": "您是從哪裡得知 ComfyUI 的?",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudSurvey_steps_source_social": "哪個平台",
"cloudWaitlist_contactLink": "此處",
"cloudWaitlist_questionsText": "有問題?聯絡我們",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "请将您的回答控制在 {max} 个字符以内。",
"chooseAnOption": "请选择一个选项。",
"describeAnswer": "请描述您的答案。",
"selectAtLeastOne": "请至少选择一个选项。"
},
"intro": "帮助我们为您定制 ComfyUI 体验。",
"options": {
"familiarity": {
"advanced": "高级用户(自定义工作流)",
"basics": "熟练掌握基础知识",
"expert": "专家(帮助他人)",
"new": "ComfyUI 新手(从未使用过)",
"starting": "刚刚开始(正在学习教程)"
"experience": {
"new": "ComfyUI 新手",
"pro": "我是高级用户",
"some": "我已经熟悉操作"
},
"focus": {
"custom_nodes": "自定义节点",
"pipelines": "自动化流程",
"products": "为他人制作产品"
},
"intent": {
"3d_game": "3D 资产 / 游戏资产",
"api": "运行工作流的 API 端点",
"apps": "基于工作流的简化应用",
"audio": "音频 / 音乐",
"custom_nodes": "自定义节点",
"apps_api": "应用和 API",
"exploring": "只是探索一下",
"images": "图像",
"not_sure": "不确定",
"videos": "视频",
"other": "其他",
"otherPlaceholder": "你想做什么?",
"video": "视频",
"workflows": "自定义工作流或流程"
},
"source": {
"conference": "会议或活动",
"discord": "Discord / 社区",
"community": "社区或论坛",
"friend": "朋友或同事",
"github": "GitHub",
"other": "其他",
"otherPlaceholder": "你是从哪里了解到我们的?",
"search": "Google / 搜索",
"social": "社交媒体"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "新闻通讯或博客",
"other": "其他",
"reddit": "Reddit",
"search": "Google / 搜索",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X推特",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生或教师)",
"personal": "个人使用",
"work": "工作"
}
},
"otherPlaceholder": "请告诉我们更多",
"placeholder": "调查问题占位符",
"steps": {
"familiarity": "你对 ComfyUI 有多熟悉?",
"intent": "您希望用 ComfyUI 创作什么?",
"source": "您是从哪里了解到 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "云调研"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "了解云服务",
"cloudStart_title": "几秒钟内开始创作",
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉",
"cloudSurvey_steps_experience": "你对 ComfyUI 有多了解",
"cloudSurvey_steps_focus": "你正在构建什么?",
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudSurvey_steps_source_social": "你是在哪个平台上看到的",
"cloudWaitlist_contactLink": "这里",
"cloudWaitlist_questionsText": "有问题?联系我们",
"color": {

View File

@@ -9,20 +9,6 @@ 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>) =>
@@ -228,7 +214,6 @@ describe('AssetBrowserModal', () => {
vi.resetAllMocks()
mockAssetsByKey.clear()
mockLoadingByKey.clear()
mockSupportsModelTypeTags.value = false
})
describe('Integration with useAssetBrowser', () => {
@@ -435,20 +420,5 @@ 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"}'
)
})
})
})

View File

@@ -100,7 +100,6 @@ 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'
@@ -110,14 +109,12 @@ 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)
@@ -194,21 +191,9 @@ 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) => getPrimaryCategoryTag(asset, modelTypeMode))
.map((asset) => asset.tags?.find((tag) => tag !== 'models'))
.find((tag): tag is string => typeof tag === 'string' && tag.length > 0)
if (tagFromAssets) return tagFromAssets

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
@@ -14,14 +14,6 @@ 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',
@@ -49,10 +41,6 @@ describe('ModelInfoPanel', () => {
...overrides
})
afterEach(() => {
mockDistribution.isCloud = false
})
function renderPanel(asset: AssetDisplayItem) {
return render(ModelInfoPanel, {
props: { asset },
@@ -150,18 +138,6 @@ 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'] }

View File

@@ -71,7 +71,7 @@
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
<Select v-if="isModelTypeEditable" v-model="selectedModelType">
<Select v-if="!isImmutable" v-model="selectedModelType">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
@@ -215,7 +215,6 @@ 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'
@@ -230,19 +229,17 @@ 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'
@@ -268,7 +265,6 @@ const { asset, cacheKey, selectContentStyle } = defineProps<{
}>()
const assetsStore = useAssetsStore()
const { flags } = useFeatureFlags()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref<AssetUserMetadata>({})
@@ -276,9 +272,6 @@ 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)
)
@@ -325,17 +318,12 @@ function handleDisplayNameEdit(newName: string) {
}
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
if (!isModelTypeEditable.value) return
const currentModelType = getEditableModelType(
asset,
flags.supportsModelTypeTags
)
if (isImmutable.value) return
const currentModelType = getAssetModelType(asset)
if (currentModelType === newModelType) return
const newTags = buildModelTypeTagUpdate(
asset,
newModelType,
flags.supportsModelTypeTags
)
const newTags = asset.tags
.filter((tag) => tag !== currentModelType)
.concat(newModelType)
assetsStore.updateAssetTags(asset, newTags, cacheKey)
}, 500)
@@ -357,10 +345,7 @@ const userDescription = computed({
})
const selectedModelType = computed({
get: () =>
pendingModelType.value ??
getEditableModelType(asset, flags.supportsModelTypeTags) ??
undefined,
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
set: (value: string | undefined) => {
if (!value) return
pendingModelType.value = value

View File

@@ -25,22 +25,10 @@ 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
@@ -150,25 +138,6 @@ 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']
@@ -699,34 +668,6 @@ 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

View File

@@ -19,13 +19,10 @@ import {
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetCategories,
getAssetFilename,
getAssetTypeBadges
getAssetFilename
} 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'
@@ -46,19 +43,18 @@ export interface AssetDisplayItem extends AssetItem {
}
}
const displayItemCache = new WeakMap<
AssetItem,
{ modelTypeMode: boolean; item: AssetDisplayItem }
>()
const displayItemCache = new WeakMap<AssetItem, AssetDisplayItem>()
function buildDisplayItem(
asset: AssetItem,
modelTypeMode: boolean
): AssetDisplayItem {
function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
const badges: AssetBadge[] = []
for (const typeBadge of getAssetTypeBadges(asset, modelTypeMode)) {
badges.push({ label: typeBadge, type: 'type' })
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 model of getAssetBaseModels(asset)) {
@@ -79,15 +75,12 @@ function buildDisplayItem(
}
}
function transformAssetForDisplay(
asset: AssetItem,
modelTypeMode: boolean
): AssetDisplayItem {
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
const cached = displayItemCache.get(asset)
if (cached && cached.modelTypeMode === modelTypeMode) return cached.item
const item = buildDisplayItem(asset, modelTypeMode)
displayItemCache.set(asset, { modelTypeMode, item })
return item
if (cached) return cached
const built = buildDisplayItem(asset)
displayItemCache.set(asset, built)
return built
}
/**
@@ -100,7 +93,6 @@ export function useAssetBrowser(
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
const assetDownloadStore = useAssetDownloadStore()
const { sessionDownloadCount } = storeToRefs(assetDownloadStore)
const { flags } = useFeatureFlags()
// State
const searchQuery = ref('')
@@ -130,10 +122,12 @@ export function useAssetBrowser(
})
const typeCategories = computed<NavItemData[]>(() => {
const modelTypeMode = flags.supportsModelTypeTags
const categories = assets.value
.filter((asset) => asset.tags.includes(MODELS_TAG))
.flatMap((asset) => getAssetCategories(asset, modelTypeMode))
.flatMap((asset) =>
asset.tags.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
)
.map((tag) => tag.split('/')[0])
return Array.from(new Set(categories))
.sort()
@@ -197,9 +191,7 @@ 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, flags.supportsModelTypeTags)
)
return assets.value.filter(filterByCategory(selectedCategory.value))
})
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
@@ -256,10 +248,7 @@ export function useAssetBrowser(
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
// Transform to display format
const modelTypeMode = flags.supportsModelTypeTags
return sortedAssets.map((asset) =>
transformAssetForDisplay(asset, modelTypeMode)
)
return sortedAssets.map(transformAssetForDisplay)
})
function updateFilters(newFilters: AssetFilterState) {

View File

@@ -38,10 +38,7 @@ vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addEventListener: vi.fn(),
apiURL: vi.fn((path: string) => path),
getServerFeature: vi.fn(
(_name: string, defaultValue?: unknown) => defaultValue
)
apiURL: vi.fn((path: string) => path)
}
}))
@@ -282,43 +279,6 @@ 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')
@@ -387,65 +347,6 @@ 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')

View File

@@ -2,7 +2,6 @@ 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'
@@ -12,11 +11,7 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import {
getAssetFilename,
stripModelTypePrefix,
toModelTypeTag
} from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -73,7 +68,6 @@ export function useUploadModelWizard(
options: UploadModelWizardOptions = {}
) {
const { t } = useI18n()
const { flags } = useFeatureFlags()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
@@ -277,22 +271,19 @@ export function useUploadModelWizard(
}
function getImportedModelType(asset: AssetItem): string | undefined {
const subtypeTags = asset.tags
.filter((tag) => tag !== MODEL_ROOT_TAG)
.map(stripModelTypePrefix)
return (
subtypeTags.find((tag) =>
const knownType = asset.tags.find(
(tag) =>
tag !== MODEL_ROOT_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.map(stripModelTypePrefix).includes(requiredType))
return false
if (asset.tags.includes(requiredType)) return false
const importedType = getImportedModelType(asset)
uploadStatus.value = 'error'
@@ -326,11 +317,7 @@ export function useUploadModelWizard(
try {
const modelType = resolvedModelType.value
const subtypeTag =
modelType && flags.supportsModelTypeTags
? toModelTypeTag(modelType)
: modelType
const tags = subtypeTag ? [MODEL_ROOT_TAG, subtypeTag] : [MODEL_ROOT_TAG]
const tags = modelType ? ['models', modelType] : ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||

View File

@@ -11,8 +11,6 @@ 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(),
@@ -29,6 +27,11 @@ 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(),
@@ -97,6 +100,7 @@ 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 */
@@ -128,10 +132,4 @@ 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[]
}

View File

@@ -12,7 +12,6 @@ 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() {
@@ -20,16 +19,6 @@ 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
@@ -51,9 +40,7 @@ vi.mock('@/stores/modelToNodeStore', () => {
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addCustomEventListener: vi.fn(),
removeCustomEventListener: vi.fn()
fetchApi: vi.fn()
}
}))
@@ -100,7 +87,6 @@ function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'model.safetensors',
loader_path: overrides.name ?? 'model.safetensors',
tags: ['models'],
...overrides
}
@@ -430,330 +416,33 @@ describe(assetService.deleteAsset, () => {
})
})
describe(assetService.getAssetModels, () => {
describe(assetService.getAssetModelFolders, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateModelBuckets()
mockSupportsModelTypeTags.value = true
})
it('walks the models tag once, excluding missing assets', async () => {
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'model_type:checkpoints'] })
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
])
)
await assetService.getAssetModels('checkpoints')
const folders = await assetService.getAssetModelFolders()
expect(folders).toEqual([
{ name: 'checkpoints', folders: [] },
{ name: 'loras', folders: [] }
])
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.has('include_public')).toBe(false)
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('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(
buildResponse({ status: 'started' }, { status: 202 })
)
await assetService.seedModelAssets()
expect(fetchApiMock).toHaveBeenCalledWith('/assets/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roots: ['models'] })
})
})
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')
})
})
describe(assetService.updateAsset, () => {

View File

@@ -1,11 +1,9 @@
import { fromZodError } from 'zod-validation-error'
import { z } from 'zod'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import {
assetFilenameSchema,
assetItemSchema,
assetResponseSchema,
asyncUploadResponseSchema,
@@ -19,9 +17,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'
@@ -182,7 +180,6 @@ 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`
@@ -190,8 +187,6 @@ 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. */
@@ -214,48 +209,6 @@ 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
@@ -316,26 +269,6 @@ 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++
@@ -397,156 +330,51 @@ function createAssetService() {
return validateAssetResponse(data)
}
/**
* 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.
* 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
*/
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[]>()
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
{ includeTags: [MODELS_TAG] },
'model folders'
)
for (const asset of assets) {
const folders = asset.tags
.map((tag) => modelFolderFromTag(tag, modelTypeMode))
.filter((folder): folder is string => folder !== undefined)
// Blacklist directories we don't want to show
const blacklistedDirectories = new Set(['configs'])
if (folders.length === 0) {
console.warn(
`Asset ${asset.id} (${asset.name}) is tagged '${MODELS_TAG}' but has no model category; skipping.`
)
continue
}
const folderTags = data.assets
.flatMap((asset) => asset.tags)
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
const discoveredFolders = new Set<string>(folderTags)
// 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
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
return sortedFolders.map((name) => ({ name, folders: [] }))
}
/**
* Gets the models in the specified folder from the single models walk.
* Gets a list of models in the specified folder from the asset API
* @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 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.
const data = await handleAssetRequest(
{ includeTags: [MODELS_TAG, folder] },
`models for ${folder}`
)
return data.assets.map((asset) => ({
name: asset.name,
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
*
@@ -1143,10 +971,8 @@ function createAssetService() {
}
return {
getAssetModelFolders,
getAssetModels,
invalidateModelBuckets,
onModelsScanned,
seedModelAssets,
isAssetAPIEnabled,
isAssetBrowserEligible,
shouldUseAssetBrowser,

View File

@@ -45,7 +45,7 @@ describe('assetFilterUtils properties', () => {
it('filterByCategory("all") accepts every asset', () => {
fc.assert(
fc.property(arbAssetItem, (asset) => {
expect(filterByCategory('all', false)(asset)).toBe(true)
expect(filterByCategory('all')(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, false)
const filter = filterByCategory(category)
const result = assets.filter(filter)
expect(result.length).toBeLessThanOrEqual(assets.length)
for (const item of result) {

View File

@@ -26,54 +26,19 @@ function createAsset(
describe('filterByCategory', () => {
it.for([
{ 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: 'all', tags: ['checkpoint'], expected: true },
{ category: 'checkpoint', tags: ['checkpoint'], expected: true },
{ category: 'lora', tags: ['checkpoint'], expected: false },
{
category: 'checkpoint',
tags: ['models', 'checkpoint/xl'],
mode: false,
expected: true
},
{
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: 'xl', tags: ['models', 'checkpoint/xl'], expected: false }
])(
'category=$category tags=$tags mode=$mode returns $expected',
({ category, tags, mode, expected }) => {
const filter = filterByCategory(category, mode)
'category=$category with tags=$tags returns $expected',
({ category, tags, expected }) => {
const filter = filterByCategory(category)
const asset = createAsset('model.safetensors', { tags })
expect(filter(asset)).toBe(expected)
}

View File

@@ -1,14 +1,21 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
import {
getAssetBaseModels,
getAssetCategories
} from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
export function filterByCategory(category: string, modelTypeMode: boolean) {
export function filterByCategory(category: string) {
return (asset: AssetItem) => {
if (category === 'all') return true
return getAssetCategories(asset, modelTypeMode).includes(category)
// 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
})
}
}

View File

@@ -2,34 +2,22 @@ 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,
stripModelTypePrefix,
toModelTypeTag
resolveDisplayImageDimensions
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
@@ -286,17 +274,7 @@ describe('assetMetadataUtils', () => {
tags: ['models'],
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: 'returns null when tags empty', tags: [], expected: null }
])('$name', ({ tags, expected }) => {
const asset = { ...mockAsset, tags }
expect(getAssetModelType(asset)).toBe(expected)
@@ -562,353 +540,3 @@ 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()
})
})

View File

@@ -2,11 +2,6 @@ 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.
@@ -145,236 +140,16 @@ export function getSourceName(url: string): string {
return 'Source'
}
export const MODEL_TYPE_TAG_PREFIX = 'model_type:'
/**
* 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.
* Extracts the model type from asset tags
* @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_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
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

View File

@@ -1,25 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { 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',
@@ -60,11 +49,6 @@ describe('resolveModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockSupportsModelTypeTags.value = false
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('valid assets', () => {
@@ -84,48 +68,6 @@ 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(
@@ -259,7 +201,7 @@ describe('resolveModelNodeFromAsset', () => {
if (!result.success) {
expect(result.error.code).toBe('NO_PROVIDER')
expect(result.error.message).toContain('checkpoints')
expect(result.error.details?.candidates).toEqual(['checkpoints'])
expect(result.error.details?.category).toBe('checkpoints')
}
})
})

View File

@@ -4,11 +4,7 @@ import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import {
getAssetFilename,
getAssetNodeCategoryCandidates
} from '@/platform/assets/utils/assetMetadataUtils'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
@@ -85,12 +81,10 @@ export function resolveModelNodeFromAsset(
}
}
const { flags } = useFeatureFlags()
const candidates = getAssetNodeCategoryCandidates(
validAsset,
flags.supportsModelTypeTags
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
if (candidates.length === 0) {
if (!category) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
@@ -105,31 +99,19 @@ export function resolveModelNodeFromAsset(
}
}
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(', ')}`
)
const provider = useModelToNodeStore().getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${candidates.join(', ')}`,
message: `No node provider registered for category: ${category}`,
assetId: validAsset.id,
details: { candidates }
details: { category }
}
}
}
return { success: true, value: { provider: resolved.provider, filename } }
return { success: true, value: { provider, filename } }
}

View File

@@ -1,7 +1,5 @@
<template>
<div
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
>
<div class="dark-theme flex max-h-full w-full max-w-md flex-col 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"
>

View File

@@ -0,0 +1,618 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
/**
* Every test drives a real in-memory router and the real preserved-query
* manager: the tracker strips the code from the URL at capture time, so the
* stash is the only carrier, and redemption fires from router.afterEach, an
* auth watcher, and a delayed retry after a transient failure.
*
* The fake clock (installed for every test) keeps those retry timers from
* leaking into later tests: afterEach discards them with vi.useRealTimers().
*/
const mockConfirm = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
interface MockAuthStore {
currentUser: {
uid: string
getIdToken: (forceRefresh?: boolean) => Promise<string>
} | null
getIdToken: () => Promise<string>
}
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
// Reactive so the module's watcher on currentUser fires without a navigation.
// The mock factory is cached across vi.resetModules(), so it reads a holder
// refilled per test; watchers leaked by earlier module generations stay
// subscribed to earlier stores and remain dormant.
const authStoreHolder = vi.hoisted(() => ({
store: null as MockAuthStore | null
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => authStoreHolder.store
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
const VALID_CODE = `dlc_${'A'.repeat(43)}`
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
const NAMESPACE = 'desktop_login'
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
const RETRY_DELAY_MS = 5_000
const mockFetch = vi.fn()
let mockAuthStore: MockAuthStore
function okResponse() {
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
}
function expectedFetchOptions(code: string) {
return {
method: 'POST',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code }),
signal: expect.any(AbortSignal)
}
}
// The triggers fire-and-forget the redemption; a zero-length advance of the
// fake clock yields the event loop so the whole mocked promise chain settles.
async function flushRedemption() {
await vi.advanceTimersByTimeAsync(0)
}
// vi.resetModules() also resets the preserved-query manager's in-memory map,
// so the manager must be imported alongside the module under test.
async function setup() {
const { installDesktopLoginRedemption } =
await import('./desktopLoginRedemption')
const { capturePreservedQuery, getPreservedQueryParam } =
await import('@/platform/navigation/preservedQueryManager')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
installDesktopLoginRedemption(router)
let navigationCount = 0
const trigger = async () => {
await router.push(`/trigger-${navigationCount++}`)
await flushRedemption()
}
return {
router,
trigger,
seedStash: (code: string) =>
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
'desktop_login_code'
]),
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
}
}
describe('installDesktopLoginRedemption', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(console, 'warn').mockImplementation(() => {})
mockFetch.mockReset()
mockConfirm.mockResolvedValue(true)
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
mockAuthStore = reactive({
currentUser: {
uid: 'user-1',
getIdToken: mockUserGetIdToken
},
getIdToken: mockStoreGetIdToken
})
authStoreHolder.store = mockAuthStore
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('does nothing on navigation when no code is stashed', async () => {
const { trigger } = await setup()
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockConfirm).toHaveBeenCalledWith({
title: 'desktopLogin.confirmSummary',
message: 'desktopLogin.confirmMessage'
})
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'desktopLogin.successSummary',
detail: 'desktopLogin.successDetail',
life: 4000
})
})
it('does not fetch before the user approves the confirmation dialog', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
expect(mockFetch).not.toHaveBeenCalled()
approve(true)
await flushRedemption()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it.for([
['declines', false],
['dismisses', null]
] as const)(
'clears the stash without a request or toast when the user %s the dialog',
async ([_label, confirmResult]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockResolvedValue(confirmResult)
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
// Declining is final for that code: re-capturing it never re-prompts.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
}
)
it('asks for approval at most once per code across transient retries', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('redeems a code hydrated lazily from sessionStorage', async () => {
const { trigger } = await setup()
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ desktop_login_code: VALID_CODE })
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
})
it('does not redeem or prompt again after a successful redemption', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
// A later navigation re-captures the already-redeemed code.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBeUndefined()
})
it.for([400, 403, 404, 409, 410])(
'clears the stash, shows an error toast, and never retries on %s',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.expiredSummary',
detail: 'desktopLogin.expiredDetail',
life: 6000
})
}
)
it.for([401, 500])(
'keeps the stash on %s for the scheduled retry without a toast',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
}
)
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.failedSummary',
detail: 'desktopLogin.failedDetail',
life: 6000
})
})
it('forces a token refresh on the retry after a 401', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(okResponse())
await trigger()
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('passes a timeout signal and treats an aborted request as transient', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockRejectedValue(
new DOMException('The operation timed out.', 'TimeoutError')
)
await trigger()
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('treats an id token failure as transient without a toast', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
// authStore.getIdToken surfaces failures through a modal error dialog,
// which this background flow must never trigger.
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('clears the stash without a dialog or request for a malformed code', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash('not-a-desktop-login-code')
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
})
it('contains an unexpected internal error instead of rejecting', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
await expect(trigger()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[DesktopLoginRedemption] Redemption failed:',
expect.any(Error)
)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockAuthStore.currentUser = null
mockFetch.mockResolvedValue(okResponse())
// The first completed navigation installs the watcher; without a session
// nothing redeems and the stash is kept.
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
// A session appearing without any further navigation redeems via the
// watcher.
mockAuthStore.currentUser = {
uid: 'user-1',
getIdToken: mockUserGetIdToken
}
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
] as const)(
'gives a second code its own dialog and request after the first code %s',
async ([_label, arrangeFirstOutcome]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
arrangeFirstOutcome()
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
seedStash(SECOND_CODE)
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(3)
expect(stashedCode()).toBe(SECOND_CODE)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(4)
expect(stashedCode()).toBeUndefined()
})
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 500 }))
.mockResolvedValueOnce(okResponse())
// user-1 approves; the redeem fails transiently, keeping the code stashed.
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
// The session changes to user-2 before the retry: user-1's approval must
// not authorize redeeming with user-2's token.
mockAuthStore.currentUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
const { seedStash, stashedCode, trigger } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
// The session swaps to user-2 while user-1's dialog is open: the stale
// approval must not redeem, and the raced auth trigger is replayed to
// re-prompt under user-2 without another navigation.
const secondUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
mockAuthStore.currentUser = secondUser
await flushRedemption()
approve(true)
await flushRedemption()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeds', () => okResponse()],
['fails terminally', () => new Response(null, { status: 404 })]
] as const)(
'processes a newer code stashed mid-flight after the older redemption %s',
async ([_label, firstResponse]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
let resolveFirstFetch!: (response: Response) => void
mockFetch.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveFirstFetch = resolve
})
)
await trigger()
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
// A second code arrives while the first redemption is in flight; it
// must survive the first's settlement and be processed right after.
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
resolveFirstFetch(firstResponse())
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('coalesces concurrent triggers into one dialog and one request', async () => {
const { router, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await router.push('/burst-1')
await router.push('/burst-2')
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
approve(true)
await flushRedemption()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,263 @@
import { watch } from 'vue'
import type { Router } from 'vue-router'
import { t } from '@/i18n'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
// backend stays the authority on exact code length.
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
// Statuses that mean the desktop app must start a fresh sign-in, so the code
// is dropped. 401 stays transient: the session may still be settling.
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
// One delayed in-page retry, so an approved sign-in always reaches a success
// or failure toast without ever looping within a page load.
const MAX_REDEEM_ATTEMPTS = 2
const RETRY_DELAY_MS = 5_000
// Abort the redeem request if the backend hangs; treated as transient.
const REDEEM_TIMEOUT_MS = 10_000
interface CodeRedemptionState {
attempts: number
approvedUserUid: string | null
settled: boolean
forceTokenRefresh: boolean
}
// Keyed by code so a different code arriving later gets its own approval and
// attempt budget, while retries of the same code reuse both.
const codeStates = new Map<string, CodeRedemptionState>()
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
// more pass instead of being dropped.
let draining = false
let retriggerRequested = false
let authWatcherInstalled = false
function getCodeState(code: string): CodeRedemptionState {
const existing = codeStates.get(code)
if (existing) return existing
const fresh = {
attempts: 0,
approvedUserUid: null,
settled: false,
forceTokenRefresh: false
}
codeStates.set(code, fresh)
return fresh
}
// A newer code can be stashed while an older one is mid-redemption; settling
// the older one must not wipe it.
function clearStashIfHolds(code: string): void {
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
clearPreservedQuery(NAMESPACE)
}
}
function settle(code: string, state: CodeRedemptionState): void {
state.settled = true
clearStashIfHolds(code)
}
function handleTransientFailure(
code: string,
state: CodeRedemptionState,
reason: string
): void {
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
// attempts only increments, so this branch runs at most once per code
// and cannot stack retry timers.
setTimeout(() => {
void redeemPendingDesktopLoginCode()
}, RETRY_DELAY_MS)
return
}
// Budget spent: drop the code and tell the user instead of failing silently.
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.failedSummary'),
detail: t('desktopLogin.failedDetail'),
life: 6000
})
}
// Explicit approval defeats device-code phishing: a lured click on a leaked
// link must not bind the victim's session to an attacker's desktop app.
// Approval is per code *and* account.
async function confirmRedemption(
state: CodeRedemptionState,
uid: string
): Promise<boolean> {
if (state.approvedUserUid === uid) return true
const confirmed = await useDialogService().confirm({
title: t('desktopLogin.confirmSummary'),
message: t('desktopLogin.confirmMessage')
})
if (confirmed !== true) return false
state.approvedUserUid = uid
return true
}
async function redeemCode(code: string): Promise<void> {
const state = getCodeState(code)
if (state.settled) {
// A later navigation can re-capture an already-settled code; drop it.
clearStashIfHolds(code)
return
}
// No session yet (e.g. code captured on the login page): keep the stash and
// let a post-login trigger redeem it.
const user = useAuthStore().currentUser
if (!user) return
if (!(await confirmRedemption(state, user.uid))) {
// Declined/dismissed: drop the code without an error.
settle(code, state)
return
}
// Approval binds the code to one account: if the session changed while the
// dialog was open, keep the code stashed and let the (replayed) auth-change
// trigger re-prompt under the now-current account.
const approvedUser = useAuthStore().currentUser
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
state.attempts++
// Token comes straight from the Firebase user: authStore.getIdToken()
// surfaces failures through a modal dialog this background flow must avoid.
let idToken: string
try {
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
} catch {
handleTransientFailure(code, state, 'could not get id token')
return
}
let response: Response
try {
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
method: 'POST',
headers: {
Authorization: `Bearer ${idToken}`,
'Content-Type': 'application/json'
},
// TODO(@comfyorg/ingest-types): type the payload with the generated
// request type once the desktop-login-codes openapi addition propagates.
body: JSON.stringify({ code }),
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
})
} catch (error) {
handleTransientFailure(
code,
state,
error instanceof Error && error.name === 'TimeoutError'
? 'request timed out'
: 'network error'
)
return
}
if (response.ok) {
settle(code, state)
useToastStore().add({
severity: 'success',
summary: t('desktopLogin.successSummary'),
detail: t('desktopLogin.successDetail'),
life: 4000
})
return
}
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.expiredSummary'),
detail: t('desktopLogin.expiredDetail'),
life: 6000
})
return
}
// A 401 usually means a stale cached id token; mint a fresh one on retry.
if (response.status === 401) state.forceTokenRefresh = true
handleTransientFailure(code, state, `status ${response.status}`)
}
async function redeemPendingDesktopLoginCode(): Promise<void> {
// Never rejects: the triggers fire-and-forget this.
if (draining) {
retriggerRequested = true
return
}
draining = true
try {
do {
retriggerRequested = false
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
if (!code) continue
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
clearPreservedQuery(NAMESPACE)
continue
}
await redeemCode(code)
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
retriggerRequested = true
} while (retriggerRequested)
} catch (error) {
console.error('[DesktopLoginRedemption] Redemption failed:', error)
} finally {
draining = false
}
}
function installAuthWatcherOnce(): void {
if (authWatcherInstalled) return
authWatcherInstalled = true
// A session can appear without a navigation (e.g. dialog-based sign-in).
// Installed lazily because pinia is not active when router.ts evaluates.
watch(
() => useAuthStore().currentUser,
() => {
void redeemPendingDesktopLoginCode()
}
)
}
/**
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
*
* The desktop app opens the browser with an opaque one-time code and polls
* the cloud backend; redeeming the code from a signed-in browser session,
* with the user's approval, releases a one-time custom token to that poll
* and signs the desktop app in. The preserved-query tracker (configured in
* router.ts) strips the code from the URL at capture time, so the stash is
* the only place it lives.
*/
export function installDesktopLoginRedemption(router: Router): void {
router.afterEach(() => {
installAuthWatcherOnce()
void redeemPendingDesktopLoginCode()
})
}

View File

@@ -13,7 +13,7 @@
</div>
<div
class="overflow-hidden transition-[height] duration-300 ease-out"
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
:style="animatedHeightStyle"
>
<div ref="questionContent" class="relative">

View File

@@ -5,5 +5,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
SHARE_AUTH: 'share_auth',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth',
PRICING: 'pricing'
PRICING: 'pricing',
DESKTOP_LOGIN: 'desktop_login'
} as const

View File

@@ -19,7 +19,9 @@ const env = vi.hoisted(() => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
billingType: 'legacy' as 'legacy' | 'workspace',
workspaceType: 'personal' as 'personal' | 'team',
workspaceRole: 'owner' as 'owner' | 'member'
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
@@ -70,6 +72,13 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
workspaceType: env.fakeRef('workspaceType'),
workspaceRole: env.fakeRef('workspaceRole')
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(),
getSettingInfo: vi.fn()
@@ -116,7 +125,9 @@ describe('useSettingUI', () => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
billingType: 'legacy',
workspaceType: 'personal',
workspaceRole: 'owner'
})
vi.mocked(useSettingStore).mockReturnValue({
@@ -180,6 +191,27 @@ describe('useSettingUI', () => {
expect(defaultCategory.value.key).toBe('about')
})
it('shows the partner-node prototype only to team workspace owners', () => {
Object.assign(env.state, {
isCloud: true,
isLoggedIn: true,
teamWorkspacesEnabled: true,
workspaceType: 'team',
workspaceRole: 'owner'
})
const ownerNavIds = useSettingUI().navGroups.value.flatMap((group) =>
group.items.map((item) => item.id)
)
expect(ownerNavIds).toContain('workspace-partner-nodes')
env.state.workspaceRole = 'member'
const memberNavIds = useSettingUI().navGroups.value.flatMap((group) =>
group.items.map((item) => item.id)
)
expect(memberNavIds).not.toContain('workspace-partner-nodes')
})
describe('legacy billing in the workspace layout', () => {
const navKeys = (groups: { items: { id: string }[] }[]) =>
groups.flatMap((group) => group.items.map((item) => item.id))

View File

@@ -13,6 +13,7 @@ import {
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type { NavGroupData } from '@/types/navTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -28,6 +29,7 @@ const CATEGORY_ICONS: Record<string, string> = {
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Other: 'icon-[lucide--ellipsis]',
PartnerNodes: 'icon-[lucide--shield-check]',
PlanCredits: 'icon-[lucide--credit-card]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
@@ -54,6 +56,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription, type: billingType } = useBillingContext()
const { workspaceRole, workspaceType } = useWorkspaceUI()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -192,6 +195,25 @@ export function useSettingUI(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
const shouldShowPartnerNodesPrototype = computed(
() =>
shouldShowWorkspacePanel.value &&
workspaceType.value === 'team' &&
workspaceRole.value === 'owner'
)
const partnerNodesPanel: SettingPanelItem = {
node: {
key: 'workspace-partner-nodes',
label: 'PartnerNodes',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue')
)
}
const secretsPanel: SettingPanelItem = {
node: {
key: 'secrets',
@@ -245,6 +267,7 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowPartnerNodesPrototype.value ? [partnerNodesPanel] : []),
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
keybindingPanel,
extensionPanel,
@@ -296,6 +319,9 @@ export function useSettingUI(
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(shouldShowPartnerNodesPrototype.value
? [partnerNodesPanel.node]
: []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]

View File

@@ -1215,15 +1215,6 @@ 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',

View File

@@ -87,3 +87,4 @@ export type SettingPanelType =
| 'subscription'
| 'user'
| 'workspace'
| 'workspace-partner-nodes'

View File

@@ -0,0 +1,124 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import PartnerNodesPanelContent from './PartnerNodesPanelContent.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { clear: 'Clear' },
workspacePanel: {
partnerNodes: {
title: 'Partner Nodes Prototype',
description: 'Preview node discovery.',
prototypeTitle: 'Local UX prototype',
prototypeDescription:
'Changes are stored only in this browser for the active workspace.',
searchPlaceholder: 'Search partner nodes',
enableFiltered: 'Enable filtered',
disableFiltered: 'Disable filtered',
enabledCount: '{enabled} of {total} enabled',
empty: 'No partner nodes are available.',
noResults: 'No partner nodes match this search.',
toggleLabel: 'Show {node} in modern node discovery',
defaultDeny: 'New partner nodes start disabled.'
}
}
}
}
})
function createPartnerNode(
name: string,
displayName: string,
provider: string
): ComfyNodeDef {
return {
name,
display_name: displayName,
category: `api/${provider}`,
python_module: 'test',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
deprecated: false,
experimental: false,
api_node: true
}
}
function activateTeamWorkspace() {
const workspaceStore = useTeamWorkspaceStore()
workspaceStore.workspaces = [
{
id: 'workspace-1',
name: 'Acme',
type: 'team',
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: []
}
]
workspaceStore.activeWorkspaceId = 'workspace-1'
}
describe('PartnerNodesPanelContent', () => {
beforeEach(() => {
localStorage.clear()
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
activateTeamWorkspace()
useNodeDefStore().updateNodeDefs([
createPartnerNode('OpenAIImage', 'OpenAI Image', 'OpenAI'),
createPartnerNode('AdobeFirefly', 'Adobe Firefly', 'Adobe')
])
})
it('explains the prototype boundary and enables filtered nodes', async () => {
const user = userEvent.setup()
render(PartnerNodesPanelContent, {
global: {
plugins: [i18n]
}
})
expect(screen.getByText('Local UX prototype')).toBeInTheDocument()
expect(
screen.getByText(
'Changes are stored only in this browser for the active workspace.'
)
).toBeInTheDocument()
const search = screen.getByRole('combobox')
await user.type(search, 'OpenAI')
expect(screen.getByText('OpenAI Image')).toBeInTheDocument()
expect(screen.queryByText('Adobe Firefly')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Enable filtered' }))
expect(
screen.getByRole('switch', {
name: 'Show OpenAI Image in modern node discovery'
})
).toHaveAttribute('aria-checked', 'true')
})
})

View File

@@ -0,0 +1,201 @@
<template>
<div class="flex size-full flex-col gap-4">
<header>
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
{{ $t('workspacePanel.partnerNodes.title') }}
</h2>
<p class="mt-1 mb-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.partnerNodes.description') }}
</p>
</header>
<div
role="note"
class="flex gap-3 rounded-lg bg-secondary-background p-3 text-sm text-base-foreground"
>
<i
class="mt-0.5 icon-[lucide--flask-conical] size-4 shrink-0"
aria-hidden="true"
/>
<div>
<p class="m-0 font-semibold">
{{ $t('workspacePanel.partnerNodes.prototypeTitle') }}
</p>
<p class="mt-1 mb-0 text-muted-foreground">
{{ $t('workspacePanel.partnerNodes.prototypeDescription') }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
class="min-w-64 flex-1"
/>
<Button
variant="textonly"
:disabled="!filteredNodes.length"
@click="setAllFiltered(true)"
>
{{ $t('workspacePanel.partnerNodes.enableFiltered') }}
</Button>
<Button
variant="textonly"
:disabled="!filteredNodes.length"
@click="setAllFiltered(false)"
>
{{ $t('workspacePanel.partnerNodes.disableFiltered') }}
</Button>
</div>
<p
v-if="!prototypeStore.partnerNodes.length"
class="my-8 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.partnerNodes.empty') }}
</p>
<p
v-else-if="!filteredNodes.length"
class="my-8 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.partnerNodes.noResults') }}
</p>
<div v-else class="min-h-0 flex-1 overflow-y-auto pr-1">
<section
v-for="group in groupedNodes"
:key="group.provider"
class="mb-4 overflow-hidden rounded-lg border border-interface-stroke"
>
<h3
class="m-0 flex items-center justify-between bg-secondary-background px-4 py-2 text-sm font-semibold text-base-foreground"
>
<span>{{ group.provider }}</span>
<span class="font-normal text-muted-foreground">
{{
$t('workspacePanel.partnerNodes.enabledCount', {
enabled: group.enabledCount,
total: group.nodes.length
})
}}
</span>
</h3>
<ul class="m-0 list-none divide-y divide-interface-stroke p-0">
<li
v-for="node in group.nodes"
:key="node.name"
class="flex min-h-12 items-center justify-between gap-4 px-4 py-1"
>
<div class="min-w-0">
<p class="m-0 truncate text-sm text-base-foreground">
{{ node.displayName }}
</p>
<p class="m-0 truncate text-xs text-muted-foreground">
{{ node.name }}
</p>
</div>
<button
type="button"
role="switch"
:aria-checked="prototypeStore.isEnabled(node.name)"
:aria-label="
$t('workspacePanel.partnerNodes.toggleLabel', {
node: node.displayName
})
"
class="group focus-visible:ring-ring flex h-11 w-14 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent focus-visible:ring-1 focus-visible:outline-none"
@click="toggleNode(node.name)"
>
<span
:class="
cn(
'relative h-6 w-11 rounded-full border border-interface-stroke transition-colors',
prototypeStore.isEnabled(node.name)
? 'bg-primary-background group-hover:bg-primary-background-hover'
: 'bg-secondary-background group-hover:bg-secondary-background-hover'
)
"
aria-hidden="true"
>
<span
:class="
cn(
'absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm transition-transform',
prototypeStore.isEnabled(node.name) && 'translate-x-5'
)
"
/>
</span>
</button>
</li>
</ul>
</section>
</div>
<p class="m-0 text-xs text-muted-foreground">
{{ $t('workspacePanel.partnerNodes.defaultDeny') }}
</p>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import type { PartnerNodePrototypeEntry } from '@/platform/workspace/partnerNodePrototype/partnerNodePrototypeStore'
import { usePartnerNodePrototypeStore } from '@/platform/workspace/partnerNodePrototype/partnerNodePrototypeStore'
interface PartnerNodePrototypeGroup {
provider: string
nodes: PartnerNodePrototypeEntry[]
enabledCount: number
}
const prototypeStore = usePartnerNodePrototypeStore()
const searchQuery = ref('')
const filteredNodes = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return prototypeStore.partnerNodes
return prototypeStore.partnerNodes.filter(
(node) =>
node.displayName.toLowerCase().includes(query) ||
node.name.toLowerCase().includes(query) ||
node.provider.toLowerCase().includes(query)
)
})
const groupedNodes = computed<PartnerNodePrototypeGroup[]>(() => {
const nodesByProvider = new Map<string, PartnerNodePrototypeEntry[]>()
for (const node of filteredNodes.value) {
const nodes = nodesByProvider.get(node.provider) ?? []
nodes.push(node)
nodesByProvider.set(node.provider, nodes)
}
return [...nodesByProvider.entries()]
.sort(([first], [second]) => first.localeCompare(second))
.map(([provider, nodes]) => ({
provider,
nodes: nodes.toSorted((first, second) =>
first.displayName.localeCompare(second.displayName)
),
enabledCount: nodes.filter((node) => prototypeStore.isEnabled(node.name))
.length
}))
})
function setAllFiltered(enabled: boolean) {
prototypeStore.setEnabled(
filteredNodes.value.map((node) => node.name),
enabled
)
}
function toggleNode(nodeType: string) {
prototypeStore.setEnabled([nodeType], !prototypeStore.isEnabled(nodeType))
}
</script>

View File

@@ -0,0 +1,92 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { installPartnerNodePrototype } from './installPartnerNodePrototype'
import { usePartnerNodePrototypeStore } from './partnerNodePrototypeStore'
function createNodeDef(name: string, apiNode: boolean = false): ComfyNodeDef {
return {
name,
display_name: name,
category: apiNode ? 'api/OpenAI' : 'image',
python_module: 'test',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
deprecated: false,
experimental: false,
api_node: apiNode
}
}
function activateTeamWorkspace() {
const workspaceStore = useTeamWorkspaceStore()
workspaceStore.workspaces = [
{
id: 'workspace-1',
name: 'Acme',
type: 'team',
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: []
}
]
workspaceStore.activeWorkspaceId = 'workspace-1'
}
describe('installPartnerNodePrototype', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
activateTeamWorkspace()
})
it('defaults partner nodes to hidden and reveals enabled nodes', () => {
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([
createNodeDef('LoadImage'),
createNodeDef('OpenAIImage', true)
])
installPartnerNodePrototype()
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
'LoadImage'
])
usePartnerNodePrototypeStore().setEnabled(['OpenAIImage'], true)
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
'LoadImage',
'OpenAIImage'
])
})
it('does not change discovery outside an owned team workspace', () => {
const workspaceStore = useTeamWorkspaceStore()
const workspace = workspaceStore.workspaces[0]
if (!workspace) throw new Error('Expected a workspace fixture')
workspaceStore.workspaces = [{ ...workspace, role: 'member' }]
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([createNodeDef('OpenAIImage', true)])
installPartnerNodePrototype()
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
'OpenAIImage'
])
})
})

View File

@@ -0,0 +1,16 @@
import { t } from '@/i18n'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePartnerNodePrototypeStore } from './partnerNodePrototypeStore'
export function installPartnerNodePrototype() {
const prototypeStore = usePartnerNodePrototypeStore()
const nodeDefStore = useNodeDefStore()
nodeDefStore.registerNodeDefFilter({
id: 'prototype.partnerNodeVisibility',
name: t('nodeFilters.hidePrototypeDisabledPartnerNodes'),
description: t('nodeFilters.hidePrototypeDisabledPartnerNodesDescription'),
predicate: (nodeDef) => !prototypeStore.disabledNodeTypes.has(nodeDef.name)
})
}

View File

@@ -0,0 +1,87 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getProviderName } from '@/utils/categoryUtil'
export interface PartnerNodePrototypeEntry {
name: string
displayName: string
provider: string
}
interface PersistedPrototypeState {
enabledNodeTypesByWorkspace: Record<string, Record<string, boolean>>
}
const STORAGE_KEY = 'Comfy.Prototype.PartnerNodeVisibility'
export const usePartnerNodePrototypeStore = defineStore(
'partnerNodePrototype',
() => {
const nodeDefStore = useNodeDefStore()
const workspaceStore = useTeamWorkspaceStore()
const persisted = useLocalStorage<PersistedPrototypeState>(STORAGE_KEY, {
enabledNodeTypesByWorkspace: {}
})
const prototypeWorkspaceId = computed(() => {
const workspace = workspaceStore.activeWorkspace
if (workspace?.type !== 'team' || workspace.role !== 'owner') return null
return workspace.id
})
const enabledNodeTypes = computed(() => {
const workspaceId = prototypeWorkspaceId.value
if (!workspaceId) return {}
return persisted.value.enabledNodeTypesByWorkspace[workspaceId] ?? {}
})
const partnerNodes = computed<PartnerNodePrototypeEntry[]>(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((nodeDef) => nodeDef.api_node)
.map((nodeDef) => ({
name: nodeDef.name,
displayName: nodeDef.display_name,
provider: getProviderName(nodeDef.category)
}))
)
function isEnabled(nodeType: string): boolean {
return enabledNodeTypes.value[nodeType] ?? false
}
const disabledNodeTypes = computed(() => {
if (!prototypeWorkspaceId.value) return new Set<string>()
return new Set(
partnerNodes.value
.filter((node) => !isEnabled(node.name))
.map((node) => node.name)
)
})
function setEnabled(nodeTypes: string[], enabled: boolean) {
const workspaceId = prototypeWorkspaceId.value
if (!workspaceId) return
const nextEnabledNodeTypes = { ...enabledNodeTypes.value }
for (const nodeType of nodeTypes) {
nextEnabledNodeTypes[nodeType] = enabled
}
persisted.value = {
enabledNodeTypesByWorkspace: {
...persisted.value.enabledNodeTypesByWorkspace,
[workspaceId]: nextEnabledNodeTypes
}
}
}
return {
partnerNodes,
disabledNodeTypes,
isEnabled,
setEnabled
}
}
)

View File

@@ -16,6 +16,7 @@ import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installDesktopLoginRedemption } from '@/platform/cloud/onboarding/desktopLoginRedemption'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
@@ -118,6 +119,11 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
keys: ['pricing']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN,
keys: ['desktop_login_code'],
stripAfterCapture: true
}
])
@@ -249,6 +255,8 @@ if (isCloud) {
// User is logged in and accessing protected route
return next()
})
installDesktopLoginRedemption(router)
}
export default router

View File

@@ -428,7 +428,6 @@ 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(),

View File

@@ -875,113 +875,6 @@ 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()
@@ -1031,34 +924,6 @@ 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', () => {
@@ -1570,35 +1435,6 @@ 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 }))

View File

@@ -394,444 +394,421 @@ 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().
*
* Runs on every distribution; whether anything fetches through it is
* decided by consumers via `assetService.isAssetAPIEnabled()`, which stays
* the authoritative off-cloud gate.
* Cloud-only feature - empty Maps in desktop builds
*/
const getModelState = () => {
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
if (isCloud) {
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 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 createState(
existingAssets?: Map<string, AssetItem>
): ModelPaginationState {
const assets = new Map(existingAssets)
return reactive({
assets,
offset: 0,
hasMore: true,
isLoading: true
})
}
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)!
function isStale(category: string, state: ModelPaginationState): boolean {
const committed = modelStateByCategory.value.get(category)
const pending = pendingRequestByCategory.get(category)
return committed !== state && pending !== state
}
const existingState = modelStateByCategory.value.get(category)
const state = createState(existingState?.assets)
const EMPTY_ASSETS: AssetItem[] = []
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)
/**
* 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)
}
async function loadBatches(): Promise<void> {
while (state.hasMore) {
try {
const newAssets = await fetcher({
limit: MODEL_BATCH_SIZE,
offset: state.offset
})
/**
* 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
if (isStale(category, state)) return
const state = modelStateByCategory.value.get(category)
const assetsMap = state?.assets
if (!assetsMap) return EMPTY_ASSETS
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
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
}
const cached = assetsArrayCache.get(category)
if (cached && cached.source === assetsMap) {
return cached.array
}
const staleIds = [...state.assets.keys()].filter(
(id) => !seenIds.has(id)
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}`)
)
for (const id of staleIds) {
state.assets.delete(id)
}
/**
* 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)!
}
assetsArrayCache.delete(category)
if (pendingRequestByCategory.get(category) === state) {
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)
}
}
// 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)
}
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
}
}
// 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) {
const promise = loadBatches().finally(() => {
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
}
}
}
/**
* 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
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)
)
}
}
/**
* 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)
/**
* 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)
)
}
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
/**
* 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)
}
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
/**
* 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
let removedTagsOnServer: string[] = []
try {
let removeResult: TagsOperationResult | undefined
if (tagsToRemove.length > 0) {
removeResult = await assetService.removeAssetTags(
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 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,
tagsToRemove
{ user_metadata: originalMetadata },
cacheKey
)
removedTagsOnServer = removeResult.removed ?? tagsToRemove
}
}
const addResult =
tagsToAdd.length > 0
? await assetService.addAssetTags(asset.id, tagsToAdd)
: undefined
/**
* 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)
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 (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
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
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
)
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)
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)
}
}
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
}
}
/**
* 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')
}
const emptyAssets: AssetItem[] = []
return {
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags,
invalidateModelsForCategory
getAssets: () => emptyAssets,
isLoading: () => false,
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
hasCategory: () => false,
updateModelsForNodeType: async () => {},
invalidateCategory: () => {},
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {},
invalidateModelsForCategory: () => {}
}
}

View File

@@ -5,12 +5,7 @@ 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 {
ResourceState,
effectiveModelExtensions,
matchesModelExtension,
useModelStore
} from '@/stores/modelStore'
import { useModelStore } from '@/stores/modelStore'
// Mock the api
vi.mock('@/scripts/api', () => ({
@@ -20,7 +15,6 @@ 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()
}
}))
@@ -28,10 +22,8 @@ vi.mock('@/scripts/api', () => ({
// Mock the assetService
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetModels: vi.fn(),
invalidateModelBuckets: vi.fn(),
onModelsScanned: vi.fn(),
seedModelAssets: vi.fn()
getAssetModelFolders: vi.fn(),
getAssetModels: vi.fn()
}
}))
@@ -65,15 +57,16 @@ function enableMocks(useAssetAPI = false) {
{ name: 'vae', folders: ['/path/to/vae'] }
])
// Asset API supplies only the per-folder model contents; folders come from
// api.getModelFolders in both paths.
// 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'] }
])
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') {
@@ -216,193 +209,6 @@ 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', () => {
@@ -412,117 +218,28 @@ describe('useModelStore', () => {
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Folders come from /experiment/models; legacy path also serves models.
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
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 model contents but /experiment/models for folders when UseAssetAPI is true', async () => {
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
enableMocks(true) // useAssetAPI = true
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Folders always come from /experiment/models; only contents use the asset API.
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(assetService.getAssetModelFolders).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([])
)
})
})

View File

@@ -1,9 +1,8 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
import { computed, 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'
@@ -101,11 +100,6 @@ 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)
@@ -162,66 +156,6 @@ 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> = {}
@@ -229,8 +163,7 @@ export class ModelFolder {
constructor(
public directory: string,
private getModelsFunc: (folder: string) => Promise<ModelFile[]>,
public readonly extensions: string[] = []
private getModelsFunc: (folder: string) => Promise<ModelFile[]>
) {}
get key(): string {
@@ -247,7 +180,6 @@ 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,
@@ -280,33 +212,22 @@ export const useModelStore = defineStore('models', () => {
: (folder) => api.getModels(folder)
}
let modelFoldersRequestId = 0
/**
* 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.
* Loads the model folders from the server
*/
async function loadModelFolders() {
const requestId = ++modelFoldersRequestId
const resData = await api.getModelFolders()
if (requestId !== modelFoldersRequestId) return
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
const resData = useAssetAPI
? await assetService.getAssetModelFolders()
: await api.getModelFolders()
modelFolderNames.value = resData.map((folder) => folder.name)
modelFolderByName.value = {}
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
const getModelsFunc = createGetModelsFunc()
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) : []
for (const folderName of modelFolderNames.value) {
modelFolderByName.value[folderName] = new ModelFolder(
folderName,
getModelsFunc
)
}
}
@@ -319,15 +240,9 @@ export const useModelStore = defineStore('models', () => {
}
/**
* 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.
* Loads all model folders' contents from the server
*/
async function loadModels() {
if (modelFolderNames.value.length === 0) {
await loadModelFolders()
}
return Promise.all(modelFolders.value.map((folder) => folder.load()))
}
@@ -338,45 +253,25 @@ 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 requestId = modelFoldersRequestId
const refreshId = (folderRefreshIds.get(folderName) ?? 0) + 1
folderRefreshIds.set(folderName, refreshId)
const folder = new ModelFolder(
folderName,
createGetModelsFunc(),
modelFolderByName.value[folderName].extensions
)
const folder = new ModelFolder(folderName, createGetModelsFunc())
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
}
/**
* 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.
* 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.
*/
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.
async function refresh() {
const previouslyLoaded = modelFolders.value
.filter((folder) => folder.state !== ResourceState.Uninitialized)
.filter((folder) => folder.state === ResourceState.Loaded)
.map((folder) => folder.directory)
await loadModelFolders()
await Promise.all(
@@ -386,43 +281,6 @@ 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,

View File

@@ -5,24 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const {
mockGetSetting,
mockRegisterCommand,
mockRegisterCommands,
mockBrowseModelAssets,
registeredCommands,
commandStoreCommands
} = vi.hoisted(() => {
const registeredCommands: { id: string; function: () => unknown }[] = []
return {
const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } =
vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockRegisterCommand: vi.fn((command) => registeredCommands.push(command)),
mockRegisterCommands: vi.fn(),
mockBrowseModelAssets: vi.fn(),
registeredCommands,
commandStoreCommands: [] as { id: string; function: () => unknown }[]
}
})
mockRegisterCommand: vi.fn(),
mockRegisterCommands: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
@@ -33,7 +21,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
registerCommand: mockRegisterCommand,
commands: commandStoreCommands
commands: []
})
}))
@@ -111,18 +99,8 @@ 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
@@ -182,63 +160,4 @@ 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()
})
})
})

View File

@@ -76,13 +76,8 @@ 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