Compare commits

...

4 Commits

Author SHA1 Message Date
Comfy Org PR Bot
af771a45d0 [backport cloud/1.46] feat: Load3DAdvanced uploads to input/3d (#12874)
Backport of #12851 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-16 08:21:37 -04:00
Comfy Org PR Bot
cb4c3b833b [backport cloud/1.46] Simplify missing model error presentation (#12853)
Backport of #12793 to `cloud/1.46`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-15 22:16:58 +09:00
Alexander Brown
2b0bcda41f chore(deps): update oxc toolchain to 1.69 (fixes Windows oxlint OOM) (#12838)
## Summary

Update the oxc toolchain to 1.69 to fix the Windows fixed-size allocator
OOM that made oxlint commit ~130 GB per process and intermittently panic
with "Insufficient memory to create fixed-size allocator pool".

## Changes

- **What**: Bump catalog deps — `oxlint` 1.59.0 → 1.69.0,
`oxlint-tsgolint` 0.20.0 → 0.23.0, `oxfmt` 0.44.0 → 0.54.0,
`eslint-plugin-oxlint` 1.59.0 → 1.69.0. oxlint 1.69 includes the Windows
`VirtualAlloc` reserve/commit fix (oxc-project/oxc#22124, shipped in oxc
0.131.0).
- **What**: Add `--no-error-on-unmatched-pattern` to the lint-staged
oxlint command. oxlint 1.69 now exits non-zero when no lintable files
match (e.g. a yaml/lock-only commit), which broke the pre-commit hook;
this mirrors the existing stylelint `--allow-empty-input`.
- **Dependencies**: oxc toolchain only; no runtime/app dependencies
changed.

## Review Focus

- `pnpm format:check` reports no reformatting (oxfmt 0.54 produces
identical output on all 3655 files).
- `pnpm lint` passes (0 errors). Note: oxlint 1.69 adds
`typescript/no-useless-default-assignment`, which surfaces ~840
non-blocking warnings repo-wide (not auto-fixable). Left enabled here;
happy to disable in `.oxlintrc.json` in a follow-up if preferred.
- Verified locally that the new oxlint LSP commits ~0 GB instead of ~130
GB.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-14 19:33:36 +00:00
Terry Jia
b9112f9bd7 feat(load3d) FE-999: export point cloud / splat files as-is (#12810)
## Summary
PLY, SPZ, SPLAT and KSPLAT have no THREE.js exporter, so the 3D node now
offers them as export options only when the loaded file already uses
that format, and exports the original file unchanged instead of
attempting a conversion. Mesh files keep the convertible GLB/OBJ/STL/FBX
set.

Splat models (spz/splat/ksplat and gaussian-splat ply) previously had
exportable: false, which hid the export UI entirely; enable it so these
formats can be downloaded as-is. Also fixes STL export dropping its
originalURL fast-path arg.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/8417c697-eb25-4cfb-af44-3855b814fa5d
2026-06-14 04:48:06 -04:00
61 changed files with 2848 additions and 2107 deletions

View File

@@ -65,6 +65,7 @@
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unreachable": "error",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
@@ -73,12 +74,14 @@
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/no-standalone-expect": "off",
"vitest/valid-title": "off",
"vitest/require-to-throw-message": "off",
"typescript/no-this-alias": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",

View File

@@ -0,0 +1,60 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["cloud_importable_model.safetensors"]
},
{
"id": 2,
"type": "LoadImage",
"pos": [560, 100],
"size": [400, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "cloud_importable_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -60,14 +60,16 @@ export const TestIds = {
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelImport: 'missing-model-import',
missingModelImportableRows: 'missing-model-importable-rows',
missingModelLocate: 'missing-model-locate',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelReferenceCount: 'missing-model-reference-count',
missingModelUnsupportedSection:
'missing-model-import-not-supported-section',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingModelRefresh: 'missing-model-header-refresh',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',

View File

@@ -1,16 +1,29 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import type {
Asset,
AssetCreated,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
'models/checkpoints/cloud_importable_model.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
@@ -27,13 +40,62 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
}
}
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
id: 'test-existing-cloud-importable-model',
name: 'asset-record-display-name.safetensors',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
size: 2_048,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
new URL(requestUrl).searchParams
.get('include_tags')
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
function filterAssetsByRequest(
assets: ReadonlyArray<Asset>,
requestUrl: string
): Asset[] {
const includeTags = getRequestedIncludeTags(requestUrl)
return includeTags.length
? assets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: [...assets]
}
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
model_upload_button_enabled: true,
private_models_enabled: true
}
})
}
test.describe(
'Errors tab - Cloud missing models',
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableMissingModelImportFeatures(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
@@ -88,5 +150,216 @@ test.describe(
await expect(errorsTab).toBeHidden()
})
test('separates importable cloud models from unsupported rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const importableRows = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelImportableRows
)
const unsupportedSection = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelUnsupportedSection
)
await expect(
importableRows.getByRole('button', {
name: CLOUD_IMPORTABLE_MODEL_NAME,
exact: true
})
).toBeVisible()
await expect(
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
).toBeVisible()
await expect(unsupportedSection).toBeVisible()
await expect(
unsupportedSection.getByText('Import Not Supported')
).toBeVisible()
await expect(
unsupportedSection.getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeVisible()
await expect(
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
).toBeVisible()
await expect(
unsupportedSection.getByText('Unknown', { exact: true })
).toBeVisible()
await expect(
unsupportedSection.getByRole('button', {
name: 'Load Image',
exact: true
})
).toBeVisible()
await expect(
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
).toHaveCount(0)
})
test('opens cloud import with missing-model replacement context', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 1024,
final_url:
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
content_type: 'application/octet-stream',
filename: 'replacement.safetensors',
tags: ['loras']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const urlInput = comfyPage.page.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await expect(urlInput).toBeVisible()
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
)
await comfyPage.page
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
await expect(
uploadDialog.getByText(
/Locked to (Checkpoints|checkpoints) for this missing model/
)
).toBeVisible()
})
test('uses the synced asset filename when applying an already imported cloud model', async ({
comfyPage
}) => {
let isImportedAssetAvailable = false
const visibleAssets = () =>
isImportedAssetAvailable
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
: [LOTUS_DIFFUSION_MODEL]
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
const assets = filterAssetsByRequest(
visibleAssets(),
route.request().url()
)
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 2048,
final_url:
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
content_type: 'application/octet-stream',
filename: CLOUD_IMPORTABLE_MODEL_NAME,
tags: ['checkpoints']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/download', (route) => {
isImportedAssetAvailable = true
const response: AssetCreated = {
...EXISTING_CLOUD_IMPORTABLE_MODEL,
created_new: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
const urlInput = uploadDialog.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
)
await uploadDialog
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await uploadDialog
.locator('[data-attr="upload-model-step2-confirm-button"]')
.click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
?.value
})
)
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
})
}
)

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -11,6 +12,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
).toHaveText(/\S/)
})
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
test('Should display model name and metadata', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
await expect(getModelLabel(modelsGroup)).toBeVisible()
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
})
test('Should expand model row to show referencing nodes', async ({
@@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'missing/missing_models_with_nodes'
)
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
const expandButton = modelsGroup.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expectReferenceBadge(modelsGroup, 2)
await expandButton.first().click()
await expect(locateButton.first()).toBeVisible()
await expect(
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(2)
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
const copyButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
expect(copiedText).toContain('/api/devtools/')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
@@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
})
@@ -102,6 +115,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
await expect(downloadButton.first()).toHaveText('Download')
})
test('Should render Download all and Refresh actions for one downloadable model', async ({

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -8,6 +9,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
await expectReferenceBadge(missingModelGroup, 2)
})
test('Pasting a bypassed node does not add a new error', async ({
@@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(missingModelGroup).toContainText(/\(1\)/)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
})
})
@@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
@@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')

View File

@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

443
pnpm-lock.yaml generated
View File

@@ -211,8 +211,8 @@ catalogs:
specifier: ^4.16.2
version: 4.16.2
eslint-plugin-oxlint:
specifier: 1.59.0
version: 1.59.0
specifier: 1.69.0
version: 1.69.0
eslint-plugin-playwright:
specifier: ^2.10.1
version: 2.10.1
@@ -277,14 +277,14 @@ catalogs:
specifier: ^2.12.9
version: 2.12.9
oxfmt:
specifier: ^0.44.0
version: 0.44.0
specifier: ^0.54.0
version: 0.54.0
oxlint:
specifier: ^1.59.0
version: 1.59.0
specifier: ^1.69.0
version: 1.69.0
oxlint-tsgolint:
specifier: ^0.20.0
version: 0.20.0
specifier: ^0.23.0
version: 0.23.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -717,13 +717,13 @@ importers:
version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1)))(eslint@10.4.0(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3)
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0))
version: 1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0))
eslint-plugin-playwright:
specifier: 'catalog:'
version: 2.10.1(eslint@10.4.0(jiti@2.6.1))
@@ -777,13 +777,13 @@ importers:
version: 2.12.9
oxfmt:
specifier: 'catalog:'
version: 0.44.0
version: 0.54.0
oxlint:
specifier: 'catalog:'
version: 1.59.0(oxlint-tsgolint@0.20.0)
version: 1.69.0(oxlint-tsgolint@0.23.0)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.20.0
version: 0.23.0
picocolors:
specifier: 'catalog:'
version: 1.1.1
@@ -2751,276 +2751,276 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/binding-android-arm-eabi@0.44.0':
resolution: {integrity: sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==}
'@oxfmt/binding-android-arm-eabi@0.54.0':
resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.44.0':
resolution: {integrity: sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==}
'@oxfmt/binding-android-arm64@0.54.0':
resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.44.0':
resolution: {integrity: sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==}
'@oxfmt/binding-darwin-arm64@0.54.0':
resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.44.0':
resolution: {integrity: sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==}
'@oxfmt/binding-darwin-x64@0.54.0':
resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.44.0':
resolution: {integrity: sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==}
'@oxfmt/binding-freebsd-x64@0.54.0':
resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
resolution: {integrity: sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==}
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
resolution: {integrity: sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==}
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
resolution: {integrity: sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==}
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.44.0':
resolution: {integrity: sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==}
'@oxfmt/binding-linux-arm64-musl@0.54.0':
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
resolution: {integrity: sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==}
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
resolution: {integrity: sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==}
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
resolution: {integrity: sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==}
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
resolution: {integrity: sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==}
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.44.0':
resolution: {integrity: sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==}
'@oxfmt/binding-linux-x64-gnu@0.54.0':
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.44.0':
resolution: {integrity: sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==}
'@oxfmt/binding-linux-x64-musl@0.54.0':
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.44.0':
resolution: {integrity: sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==}
'@oxfmt/binding-openharmony-arm64@0.54.0':
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
resolution: {integrity: sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==}
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
resolution: {integrity: sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==}
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.44.0':
resolution: {integrity: sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==}
'@oxfmt/binding-win32-x64-msvc@0.54.0':
resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.20.0':
resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==}
'@oxlint-tsgolint/darwin-arm64@0.23.0':
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.20.0':
resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==}
'@oxlint-tsgolint/darwin-x64@0.23.0':
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.20.0':
resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==}
'@oxlint-tsgolint/linux-arm64@0.23.0':
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.20.0':
resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==}
'@oxlint-tsgolint/linux-x64@0.23.0':
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.20.0':
resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==}
'@oxlint-tsgolint/win32-arm64@0.23.0':
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.20.0':
resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==}
'@oxlint-tsgolint/win32-x64@0.23.0':
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.59.0':
resolution: {integrity: sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==}
'@oxlint/binding-android-arm-eabi@1.69.0':
resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.59.0':
resolution: {integrity: sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==}
'@oxlint/binding-android-arm64@1.69.0':
resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.59.0':
resolution: {integrity: sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==}
'@oxlint/binding-darwin-arm64@1.69.0':
resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.59.0':
resolution: {integrity: sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==}
'@oxlint/binding-darwin-x64@1.69.0':
resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.59.0':
resolution: {integrity: sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==}
'@oxlint/binding-freebsd-x64@1.69.0':
resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
resolution: {integrity: sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==}
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
resolution: {integrity: sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==}
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.59.0':
resolution: {integrity: sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==}
'@oxlint/binding-linux-arm64-gnu@1.69.0':
resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.59.0':
resolution: {integrity: sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==}
'@oxlint/binding-linux-arm64-musl@1.69.0':
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
resolution: {integrity: sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==}
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
resolution: {integrity: sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==}
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.59.0':
resolution: {integrity: sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==}
'@oxlint/binding-linux-riscv64-musl@1.69.0':
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.59.0':
resolution: {integrity: sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==}
'@oxlint/binding-linux-s390x-gnu@1.69.0':
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.59.0':
resolution: {integrity: sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==}
'@oxlint/binding-linux-x64-gnu@1.69.0':
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.59.0':
resolution: {integrity: sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==}
'@oxlint/binding-linux-x64-musl@1.69.0':
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.59.0':
resolution: {integrity: sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==}
'@oxlint/binding-openharmony-arm64@1.69.0':
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.59.0':
resolution: {integrity: sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==}
'@oxlint/binding-win32-arm64-msvc@1.69.0':
resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.59.0':
resolution: {integrity: sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==}
'@oxlint/binding-win32-ia32-msvc@1.69.0':
resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.59.0':
resolution: {integrity: sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==}
'@oxlint/binding-win32-x64-msvc@1.69.0':
resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -5395,10 +5395,10 @@ packages:
eslint-import-resolver-node:
optional: true
eslint-plugin-oxlint@1.59.0:
resolution: {integrity: sha512-g0DR+xSsnUdyaMc2KAXvBVGWz5V4GwlAE1PM+ocKxl2Eg7YgOjkRLLbxgJ3bhYOhRLhD8F0X4DjJu2FSDvrvAg==}
eslint-plugin-oxlint@1.69.0:
resolution: {integrity: sha512-ryJT8Pqb3jgWhmQcKA/D98K6UckthAR70wPTBI4rOjcaKJ9nmQkysTLbTVVEcdzfT9mznV/2MKspBsCCpXm36w==}
peerDependencies:
oxlint: ~1.59.0
oxlint: ~1.69.0
eslint-plugin-playwright@2.10.1:
resolution: {integrity: sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==}
@@ -7017,24 +7017,35 @@ packages:
oxc-resolver@11.20.0:
resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
oxfmt@0.44.0:
resolution: {integrity: sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.20.0:
resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==}
hasBin: true
oxlint@1.59.0:
resolution: {integrity: sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==}
oxfmt@0.54.0:
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.18.0'
svelte: ^5.0.0
vite-plus: '*'
peerDependenciesMeta:
svelte:
optional: true
vite-plus:
optional: true
oxlint-tsgolint@0.23.0:
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
hasBin: true
oxlint@1.69.0:
resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.22.1'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
vite-plus:
optional: true
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
@@ -8629,8 +8640,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.3:
resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==}
vue-component-type-helpers@3.3.4:
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -10739,136 +10750,136 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
optional: true
'@oxfmt/binding-android-arm-eabi@0.44.0':
'@oxfmt/binding-android-arm-eabi@0.54.0':
optional: true
'@oxfmt/binding-android-arm64@0.44.0':
'@oxfmt/binding-android-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.44.0':
'@oxfmt/binding-darwin-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-x64@0.44.0':
'@oxfmt/binding-darwin-x64@0.54.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.44.0':
'@oxfmt/binding-freebsd-x64@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.44.0':
'@oxfmt/binding-linux-arm64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.44.0':
'@oxfmt/binding-linux-x64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.44.0':
'@oxfmt/binding-linux-x64-musl@0.54.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.44.0':
'@oxfmt/binding-openharmony-arm64@0.54.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.44.0':
'@oxfmt/binding-win32-x64-msvc@0.54.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.20.0':
'@oxlint-tsgolint/darwin-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.20.0':
'@oxlint-tsgolint/darwin-x64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.20.0':
'@oxlint-tsgolint/linux-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.20.0':
'@oxlint-tsgolint/linux-x64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.20.0':
'@oxlint-tsgolint/win32-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.20.0':
'@oxlint-tsgolint/win32-x64@0.23.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.59.0':
'@oxlint/binding-android-arm-eabi@1.69.0':
optional: true
'@oxlint/binding-android-arm64@1.59.0':
'@oxlint/binding-android-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-arm64@1.59.0':
'@oxlint/binding-darwin-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-x64@1.59.0':
'@oxlint/binding-darwin-x64@1.69.0':
optional: true
'@oxlint/binding-freebsd-x64@1.59.0':
'@oxlint/binding-freebsd-x64@1.69.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.59.0':
'@oxlint/binding-linux-arm64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.59.0':
'@oxlint/binding-linux-arm64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.59.0':
'@oxlint/binding-linux-riscv64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.59.0':
'@oxlint/binding-linux-s390x-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.59.0':
'@oxlint/binding-linux-x64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.59.0':
'@oxlint/binding-linux-x64-musl@1.69.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.59.0':
'@oxlint/binding-openharmony-arm64@1.69.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.59.0':
'@oxlint/binding-win32-arm64-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.59.0':
'@oxlint/binding-win32-ia32-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.59.0':
'@oxlint/binding-win32-x64-msvc@1.69.0':
optional: true
'@package-json/types@0.0.12': {}
@@ -11312,7 +11323,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.3
vue-component-type-helpers: 3.3.4
'@swc/helpers@0.5.21':
dependencies:
@@ -13448,7 +13459,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3):
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
@@ -13461,7 +13472,7 @@ snapshots:
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 10.4.0(jiti@2.6.1)
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
transitivePeerDependencies:
- typescript
@@ -13483,10 +13494,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-oxlint@1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0)):
eslint-plugin-oxlint@1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0)):
dependencies:
jsonc-parser: 3.3.1
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
eslint-plugin-playwright@2.10.1(eslint@10.4.0(jiti@2.6.1)):
dependencies:
@@ -15453,61 +15464,61 @@ snapshots:
'@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
'@oxc-resolver/binding-win32-x64-msvc': 11.20.0
oxfmt@0.44.0:
oxfmt@0.54.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.44.0
'@oxfmt/binding-android-arm64': 0.44.0
'@oxfmt/binding-darwin-arm64': 0.44.0
'@oxfmt/binding-darwin-x64': 0.44.0
'@oxfmt/binding-freebsd-x64': 0.44.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.44.0
'@oxfmt/binding-linux-arm-musleabihf': 0.44.0
'@oxfmt/binding-linux-arm64-gnu': 0.44.0
'@oxfmt/binding-linux-arm64-musl': 0.44.0
'@oxfmt/binding-linux-ppc64-gnu': 0.44.0
'@oxfmt/binding-linux-riscv64-gnu': 0.44.0
'@oxfmt/binding-linux-riscv64-musl': 0.44.0
'@oxfmt/binding-linux-s390x-gnu': 0.44.0
'@oxfmt/binding-linux-x64-gnu': 0.44.0
'@oxfmt/binding-linux-x64-musl': 0.44.0
'@oxfmt/binding-openharmony-arm64': 0.44.0
'@oxfmt/binding-win32-arm64-msvc': 0.44.0
'@oxfmt/binding-win32-ia32-msvc': 0.44.0
'@oxfmt/binding-win32-x64-msvc': 0.44.0
'@oxfmt/binding-android-arm-eabi': 0.54.0
'@oxfmt/binding-android-arm64': 0.54.0
'@oxfmt/binding-darwin-arm64': 0.54.0
'@oxfmt/binding-darwin-x64': 0.54.0
'@oxfmt/binding-freebsd-x64': 0.54.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.54.0
'@oxfmt/binding-linux-arm-musleabihf': 0.54.0
'@oxfmt/binding-linux-arm64-gnu': 0.54.0
'@oxfmt/binding-linux-arm64-musl': 0.54.0
'@oxfmt/binding-linux-ppc64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-musl': 0.54.0
'@oxfmt/binding-linux-s390x-gnu': 0.54.0
'@oxfmt/binding-linux-x64-gnu': 0.54.0
'@oxfmt/binding-linux-x64-musl': 0.54.0
'@oxfmt/binding-openharmony-arm64': 0.54.0
'@oxfmt/binding-win32-arm64-msvc': 0.54.0
'@oxfmt/binding-win32-ia32-msvc': 0.54.0
'@oxfmt/binding-win32-x64-msvc': 0.54.0
oxlint-tsgolint@0.20.0:
oxlint-tsgolint@0.23.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.20.0
'@oxlint-tsgolint/darwin-x64': 0.20.0
'@oxlint-tsgolint/linux-arm64': 0.20.0
'@oxlint-tsgolint/linux-x64': 0.20.0
'@oxlint-tsgolint/win32-arm64': 0.20.0
'@oxlint-tsgolint/win32-x64': 0.20.0
'@oxlint-tsgolint/darwin-arm64': 0.23.0
'@oxlint-tsgolint/darwin-x64': 0.23.0
'@oxlint-tsgolint/linux-arm64': 0.23.0
'@oxlint-tsgolint/linux-x64': 0.23.0
'@oxlint-tsgolint/win32-arm64': 0.23.0
'@oxlint-tsgolint/win32-x64': 0.23.0
oxlint@1.59.0(oxlint-tsgolint@0.20.0):
oxlint@1.69.0(oxlint-tsgolint@0.23.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.59.0
'@oxlint/binding-android-arm64': 1.59.0
'@oxlint/binding-darwin-arm64': 1.59.0
'@oxlint/binding-darwin-x64': 1.59.0
'@oxlint/binding-freebsd-x64': 1.59.0
'@oxlint/binding-linux-arm-gnueabihf': 1.59.0
'@oxlint/binding-linux-arm-musleabihf': 1.59.0
'@oxlint/binding-linux-arm64-gnu': 1.59.0
'@oxlint/binding-linux-arm64-musl': 1.59.0
'@oxlint/binding-linux-ppc64-gnu': 1.59.0
'@oxlint/binding-linux-riscv64-gnu': 1.59.0
'@oxlint/binding-linux-riscv64-musl': 1.59.0
'@oxlint/binding-linux-s390x-gnu': 1.59.0
'@oxlint/binding-linux-x64-gnu': 1.59.0
'@oxlint/binding-linux-x64-musl': 1.59.0
'@oxlint/binding-openharmony-arm64': 1.59.0
'@oxlint/binding-win32-arm64-msvc': 1.59.0
'@oxlint/binding-win32-ia32-msvc': 1.59.0
'@oxlint/binding-win32-x64-msvc': 1.59.0
oxlint-tsgolint: 0.20.0
'@oxlint/binding-android-arm-eabi': 1.69.0
'@oxlint/binding-android-arm64': 1.69.0
'@oxlint/binding-darwin-arm64': 1.69.0
'@oxlint/binding-darwin-x64': 1.69.0
'@oxlint/binding-freebsd-x64': 1.69.0
'@oxlint/binding-linux-arm-gnueabihf': 1.69.0
'@oxlint/binding-linux-arm-musleabihf': 1.69.0
'@oxlint/binding-linux-arm64-gnu': 1.69.0
'@oxlint/binding-linux-arm64-musl': 1.69.0
'@oxlint/binding-linux-ppc64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-musl': 1.69.0
'@oxlint/binding-linux-s390x-gnu': 1.69.0
'@oxlint/binding-linux-x64-gnu': 1.69.0
'@oxlint/binding-linux-x64-musl': 1.69.0
'@oxlint/binding-openharmony-arm64': 1.69.0
'@oxlint/binding-win32-arm64-msvc': 1.69.0
'@oxlint/binding-win32-ia32-msvc': 1.69.0
'@oxlint/binding-win32-x64-msvc': 1.69.0
oxlint-tsgolint: 0.23.0
p-limit@3.1.0:
dependencies:
@@ -17458,7 +17469,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.3: {}
vue-component-type-helpers@3.3.4: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -79,7 +79,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.59.0
eslint-plugin-oxlint: 1.69.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
@@ -101,9 +101,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -27,6 +27,7 @@
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
:source-format="sourceFormat"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@@ -166,6 +167,7 @@ const {
canExport,
materialModes,
hasSkeleton,
sourceFormat,
hasRecording,
recordingDuration,
animations,

View File

@@ -91,6 +91,7 @@
<ExportControls
v-if="showExportControls"
:source-format="sourceFormat"
@export-model="handleExportModel"
/>
@@ -134,7 +135,8 @@ const {
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
hasSkeleton = false,
sourceFormat = null
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
@@ -143,6 +145,7 @@ const {
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -59,6 +59,7 @@ function buildViewerStub() {
canUseGizmo: ref(true),
canUseLighting: ref(true),
canExport: ref(true),
sourceFormat: ref<string | null>(null),
materialModes: ref(['original', 'normal', 'wireframe']),
animations: ref<Array<{ name: string; index: number }>>([]),
playing: ref(false),

View File

@@ -82,7 +82,10 @@
</div>
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
<ExportControls @export-model="viewer.exportModel" />
<ExportControls
:source-format="viewer.sourceFormat.value"
@export-model="viewer.exportModel"
/>
</div>
</div>
</div>

View File

@@ -13,9 +13,12 @@ const i18n = createI18n({
}
})
function renderComponent(onExportModel?: (format: string) => void) {
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
const utils = render(ExportControls, {
props: { onExportModel },
props: { onExportModel, sourceFormat },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -63,6 +66,23 @@ describe('ExportControls', () => {
).not.toBeInTheDocument()
})
it('offers only the source format for direct-export files (e.g. ply)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'ply')
await user.click(screen.getByRole('button', { name: 'Export model' }))
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
expect(
screen.queryByRole('button', { name: label })
).not.toBeInTheDocument()
}
await user.click(screen.getByRole('button', { name: 'PLY' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
it('hides the popup when a click happens outside the trigger', async () => {
const { user } = renderComponent()

View File

@@ -35,9 +35,14 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
@@ -45,12 +50,7 @@ const emit = defineEmits<{
const showExportFormats = ref(false)
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
function toggleExportFormats() {
showExportFormats.value = !showExportFormats.value

View File

@@ -72,9 +72,12 @@ const i18n = createI18n({
messages: { en: { load3d: { export: 'Export' } } }
})
function renderComponent(onExportModel?: (format: string) => void) {
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
const utils = render(ViewerExportControls, {
props: { onExportModel },
props: { onExportModel, sourceFormat },
global: { plugins: [i18n] }
})
return { ...utils, user: userEvent.setup() }
@@ -114,4 +117,32 @@ describe('ViewerExportControls', () => {
expect(onExportModel).toHaveBeenCalledWith('glb')
})
it('offers only the source format for direct-export files (e.g. spz)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'spz')
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('spz')
})
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
const onExportModel = vi.fn()
const { user, rerender } = renderComponent(onExportModel, null)
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('obj')
await rerender({ onExportModel, sourceFormat: 'ply' })
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
})

View File

@@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
@@ -34,20 +34,30 @@ import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const exportFormat = ref('obj')
watch(
exportFormats,
(formats) => {
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
exportFormat.value = formats[0]?.value ?? ''
}
},
{ immediate: true }
)
const exportModel = (format: string) => {
emit('exportModel', format)
}

View File

@@ -539,7 +539,7 @@ describe('TabErrors.vue', () => {
).toBeInTheDocument()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
@@ -557,11 +557,8 @@ describe('TabErrors.vue', () => {
}
})
expect(
screen.queryByTestId('missing-model-header-refresh')
).not.toBeInTheDocument()
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
})

View File

@@ -94,9 +94,10 @@
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
variant="muted-textonly"
size="icon"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
@@ -112,7 +113,6 @@
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span
v-if="
@@ -246,7 +246,6 @@
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
/>
@@ -301,11 +300,9 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
@@ -319,7 +316,6 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -347,7 +343,6 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -371,12 +366,6 @@ function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -463,20 +452,13 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups.value)
})
const showMissingModelHeaderRefresh = computed(
() =>
!isCloud &&
missingModelGroups.value.length > 0 &&
missingModelDownloadableModels.value.length === 0
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}

View File

@@ -169,6 +169,7 @@ describe('useLoad3d', () => {
exportModel: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,

View File

@@ -169,6 +169,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isPreview = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const sourceFormat = ref<string | null>(null)
const canFitToViewer = ref(true)
const canCenterCameraOnModel = ref(false)
const canUseGizmo = ref(true)
@@ -905,6 +906,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
sourceFormat.value = load3d?.getSourceFormat() ?? null
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
@@ -1070,6 +1072,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview,
isSplatModel,
isPlyModel,
sourceFormat,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,

View File

@@ -130,6 +130,7 @@ describe('useLoad3dViewer', () => {
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
@@ -618,7 +619,7 @@ describe('useLoad3dViewer', () => {
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: false,
exportable: false,
exportable: true,
materialModes: [],
fitTargetSize: 20
})
@@ -630,7 +631,7 @@ describe('useLoad3dViewer', () => {
expect.stringContaining('dropped.splat')
)
expect(viewer.canUseLighting.value).toBe(false)
expect(viewer.canExport.value).toBe(false)
expect(viewer.canExport.value).toBe(true)
expect(viewer.isSplatModel.value).toBe(true)
expect([...viewer.materialModes.value]).toEqual([])
})

View File

@@ -83,6 +83,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const isStandaloneMode = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const sourceFormat = ref<string | null>(null)
const canFitToViewer = ref(true)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
@@ -96,6 +97,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const captureAdapterFlags = (source: Load3d) => {
isSplatModel.value = source.isSplatModel()
isPlyModel.value = source.isPlyModel()
sourceFormat.value = source.getSourceFormat()
const caps = source.getCurrentModelCapabilities()
canFitToViewer.value = caps.fitToViewer
canUseGizmo.value = caps.gizmoTransform
@@ -839,6 +841,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isStandaloneMode,
isSplatModel,
isPlyModel,
sourceFormat,
canFitToViewer,
canUseGizmo,
canUseLighting,

View File

@@ -12,13 +12,17 @@ const {
exportGLBMock,
exportOBJMock,
exportSTLMock,
exportFBXMock
exportFBXMock,
exportDirectMock,
detectFormatFromURLMock
} = vi.hoisted(() => ({
cloneSkinnedMock: vi.fn(),
exportGLBMock: vi.fn(),
exportOBJMock: vi.fn(),
exportSTLMock: vi.fn(),
exportFBXMock: vi.fn()
exportFBXMock: vi.fn(),
exportDirectMock: vi.fn(),
detectFormatFromURLMock: vi.fn()
}))
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
@@ -30,7 +34,9 @@ vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
exportGLB: exportGLBMock,
exportOBJ: exportOBJMock,
exportSTL: exportSTLMock,
exportFBX: exportFBXMock
exportFBX: exportFBXMock,
exportDirect: exportDirectMock,
detectFormatFromURL: detectFormatFromURLMock
}
}))
@@ -1172,5 +1178,56 @@ describe('Load3d', () => {
'Unsupported export format: xyz'
)
})
it('downloads the source file directly for direct-export formats', async () => {
exportDirectMock.mockReset()
detectFormatFromURLMock.mockReturnValue('ply')
const model = new THREE.Object3D()
setupForExport({
currentModel: model,
originalFileName: 'cloud',
originalURL: 'http://example.com/api/view?filename=cloud.ply'
})
await ctx.load3d.exportModel('ply')
expect(exportDirectMock).toHaveBeenCalledWith(
'http://example.com/api/view?filename=cloud.ply',
'cloud.ply',
'ply'
)
expect(exportGLBMock).not.toHaveBeenCalled()
expect(exportOBJMock).not.toHaveBeenCalled()
expect(cloneSkinnedMock).not.toHaveBeenCalled()
})
it('refuses a direct export when the requested format differs from the source', async () => {
exportDirectMock.mockReset()
detectFormatFromURLMock.mockReturnValue('spz')
vi.spyOn(console, 'error').mockImplementation(() => {})
setupForExport({
currentModel: new THREE.Object3D(),
originalFileName: 'scene',
originalURL: 'http://example.com/api/view?filename=scene.spz'
})
await expect(ctx.load3d.exportModel('ply')).rejects.toThrow(
'Cannot export ply without converting from the loaded spz source'
)
expect(exportDirectMock).not.toHaveBeenCalled()
})
it('getSourceFormat derives the extension from the original URL', () => {
detectFormatFromURLMock.mockReturnValue('spz')
setupForExport({
currentModel: new THREE.Object3D(),
originalURL: 'http://example.com/api/view?filename=scene.spz'
})
expect(ctx.load3d.getSourceFormat()).toBe('spz')
expect(detectFormatFromURLMock).toHaveBeenCalledWith(
'http://example.com/api/view?filename=scene.spz'
)
})
})
})

View File

@@ -9,6 +9,7 @@ import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { DIRECT_EXPORT_FORMATS } from './constants'
import { ModelExporter } from './ModelExporter'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
@@ -359,6 +360,27 @@ class Load3d {
const exportMessage = `Exporting as ${format.toUpperCase()}...`
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
const originalFileName = this.modelManager.originalFileName || 'model'
const filename = `${originalFileName}.${format}`
const originalURL = this.modelManager.originalURL
if (DIRECT_EXPORT_FORMATS.has(format)) {
try {
if (this.getSourceFormat() !== format) {
throw new Error(
`Cannot export ${format} without converting from the loaded ${this.getSourceFormat() ?? 'unknown'} source`
)
}
await ModelExporter.exportDirect(originalURL, filename, format)
} catch (error) {
console.error(`Error exporting model as ${format}:`, error)
throw error
} finally {
this.eventManager.emitEvent('exportLoadingEnd', null)
}
return
}
const source = this.modelManager.currentModel
const savedPos = source.position.clone()
const savedRot = source.rotation.clone()
@@ -384,11 +406,6 @@ class Load3d {
? Object.assign(cloneSkinned(source), { animations: clips })
: source.clone()
const originalFileName = this.modelManager.originalFileName || 'model'
const filename = `${originalFileName}.${format}`
const originalURL = this.modelManager.originalURL
await new Promise((resolve) => setTimeout(resolve, 10))
switch (format) {
@@ -399,7 +416,7 @@ class Load3d {
await ModelExporter.exportOBJ(model, filename, originalURL)
break
case 'stl':
;(await ModelExporter.exportSTL(model, filename), originalURL)
await ModelExporter.exportSTL(model, filename, originalURL)
break
case 'fbx':
await ModelExporter.exportFBX(model, filename, originalURL)
@@ -421,6 +438,12 @@ class Load3d {
}
}
getSourceFormat(): string | null {
const url = this.modelManager.originalURL
if (!url) return null
return ModelExporter.detectFormatFromURL(url)
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)

View File

@@ -338,6 +338,35 @@ describe('ModelExporter', () => {
})
})
describe('exportDirect', () => {
it('downloads the original source file unchanged', async () => {
const blob = new Blob(['x'])
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
)
await ModelExporter.exportDirect(
'http://example.com/api/view?filename=src.ply',
'out.ply',
'ply'
)
expect(downloadBlobMock).toHaveBeenCalledWith('out.ply', blob)
vi.unstubAllGlobals()
})
it('throws without toasting when there is no source URL, leaving the alert to the caller', async () => {
await expect(
ModelExporter.exportDirect(null, 'out.spz', 'spz')
).rejects.toThrow('No source file available to export as spz')
expect(downloadBlobMock).not.toHaveBeenCalled()
expect(addAlertMock).not.toHaveBeenCalled()
})
})
describe('exportFBX', () => {
it('uses the direct-URL fast path for matching .fbx URLs', async () => {
const blob = new Blob(['x'])

View File

@@ -184,6 +184,18 @@ export class ModelExporter {
}
}
static async exportDirect(
originalURL: string | null | undefined,
filename: string,
format: string
): Promise<void> {
if (!originalURL) {
throw new Error(`No source file available to export as ${format}`)
}
return ModelExporter.downloadFromURL(originalURL, filename)
}
private static saveArrayBuffer(buffer: ArrayBuffer, filename: string): void {
const blob = new Blob([buffer], { type: 'application/octet-stream' })
downloadBlob(filename, blob)

View File

@@ -63,7 +63,7 @@ describe('SplatModelAdapter', () => {
const adapter = new SplatModelAdapter()
expect(adapter.kind).toBe('splat')
expect(adapter.capabilities.lighting).toBe(false)
expect(adapter.capabilities.exportable).toBe(false)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([])
})

View File

@@ -19,7 +19,7 @@ export class SplatModelAdapter implements ModelAdapter {
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: false,
exportable: false,
exportable: true,
materialModes: [],
fitTargetSize: 20
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { getExportFormatOptions } from './constants'
describe('getExportFormatOptions', () => {
it('returns the convertible mesh formats for mesh sources', () => {
expect(getExportFormatOptions('glb').map((o) => o.value)).toEqual([
'glb',
'obj',
'stl',
'fbx'
])
})
it('returns the convertible mesh formats when the source is unknown', () => {
expect(getExportFormatOptions(null).map((o) => o.value)).toEqual([
'glb',
'obj',
'stl',
'fbx'
])
})
it.each(['ply', 'spz', 'splat', 'ksplat'])(
'offers only the source format for direct-export type %s',
(format) => {
expect(getExportFormatOptions(format)).toEqual([
{ label: format.toUpperCase(), value: format }
])
}
)
it('matches direct-export formats case-insensitively', () => {
expect(getExportFormatOptions('PLY')).toEqual([
{ label: 'PLY', value: 'ply' }
])
})
})

View File

@@ -24,3 +24,27 @@ export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
].join(',')
export const LOAD3D_NONE_MODEL = 'none'
export const DIRECT_EXPORT_FORMATS = new Set(['ply', 'spz', 'splat', 'ksplat'])
export interface ExportFormatOption {
label: string
value: string
}
const CONVERTIBLE_EXPORT_FORMAT_OPTIONS: ExportFormatOption[] = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
export function getExportFormatOptions(
sourceFormat: string | null | undefined
): ExportFormatOption[] {
const format = sourceFormat?.toLowerCase()
if (format && DIRECT_EXPORT_FORMATS.has(format)) {
return [{ label: format.toUpperCase(), value: format }]
}
return CONVERTIBLE_EXPORT_FORMAT_OPTIONS
}

View File

@@ -119,6 +119,23 @@ describe('load3dLazy', () => {
expect(spec.upload_subfolder).toBe('3d')
})
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3DAdvanced', {
input: {
required: { model_file: ['STRING', {}] }
}
} as Partial<ComfyNodeDef>)
await hook({} as typeof LGraphNode, nodeData)
const spec = (
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
)[1]
expect(spec.mesh_upload).toBe(true)
expect(spec.upload_subfolder).toBe('3d')
})
it('does not throw when a Load3D node has no model_file widget spec', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3D', {

View File

@@ -61,18 +61,12 @@ useExtensionService().registerExtension({
if (isLoad3dNode(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
if (nodeData.name === 'Load3D') {
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -1401,7 +1401,6 @@ export class LGraphNode
default:
return false
break
}
this.mode = modeTo
return true

View File

@@ -3091,6 +3091,13 @@
"loadingModels": "Loading {type}...",
"maxFileSize": "Max file size: {size}",
"maxFileSizeValue": "1 GB",
"missingModelImportTypeLocked": "Locked to {type} for this missing model",
"missingModelImportTypeMismatchAlreadyImported": "This file is already imported as {actual}.",
"missingModelImportTypeMismatchNextAction": "Try importing a different {required} model that this node can use.",
"missingModelImportTypeMismatchRequired": "This node requires {required}, so this import cannot resolve the missing model.",
"missingModelImportTypeMismatchTitle": "This model cannot resolve the missing model.",
"missingModelImportUnknownType": "another model type",
"missingModelImportWillReplace": "This import will replace {model} in:",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
@@ -3643,32 +3650,17 @@
"expand": "Expand"
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",
"importing": "Importing...",
"imported": "Imported",
"importFailed": "Import failed",
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
"importAnyway": "Import Anyway",
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
"customNodeDownloadDisabled": "Nodes that reference the models below do not support imported models. Open the node to choose a supported built-in model, or replace it with a standard node that supports imported models.",
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"copyUrl": "Copy URL",
"confirmSelection": "Confirm selection",
"locateNode": "Locate node on canvas",
"cancelSelection": "Cancel selection",
"clearUrl": "Clear URL",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
"downloadAll": "Download all",
"refresh": "Refresh",
"refreshing": "Refreshing missing models.",

View File

@@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelConfirmation from './UploadModelConfirmation.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
const SingleSelectStub = {
name: 'SingleSelect',
props: {
disabled: Boolean,
modelValue: String
},
template:
'<button type="button" :disabled="disabled">{{ modelValue }}</button>'
}
describe('UploadModelConfirmation', () => {
it('shows missing-model replacement context and locks the model type', () => {
const uploadContext: UploadModelDialogContext = {
kind: 'missing-model-resolution',
missingModelName: 'segm/person_yolov8m-seg.pt',
requiredModelType: 'Ultralytics/bbox',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'Checkpoint Loader',
widgetName: 'ckpt_name'
}
]
}
render(UploadModelConfirmation, {
props: {
modelValue: 'Ultralytics/bbox',
metadata: {
content_length: 100,
final_url: 'https://civitai.com/models/123',
filename: 'replacement.safetensors'
},
uploadContext,
'onUpdate:modelValue': () => {}
},
global: {
plugins: [i18n],
stubs: {
SingleSelect: SingleSelectStub
}
}
})
expect(screen.getByText('segm/person_yolov8m-seg.pt')).toBeInTheDocument()
expect(screen.getByText('Checkpoint Loader')).toBeInTheDocument()
expect(screen.getByText('- ckpt_name')).toBeInTheDocument()
const modelTypeSelect = screen.getByRole('button', {
name: 'Ultralytics/bbox'
})
expect(modelTypeSelect).toBeDisabled()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Locked to Ultralytics/bbox for this missing model'
)
})
).toBeInTheDocument()
})
})

View File

@@ -22,16 +22,50 @@
</div>
</div>
<div
v-if="isMissingModelResolution"
class="flex flex-col gap-2 rounded-lg bg-secondary-background px-4 py-3"
>
<i18n-t
keypath="assetBrowser.missingModelImportWillReplace"
tag="p"
class="m-0 text-base-foreground"
>
<template #model>
<span>{{ missingModelName }}</span>
</template>
</i18n-t>
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="target in replacementTargets"
:key="`${target.nodeId}:${target.widgetName}`"
class="flex min-w-0 items-center gap-2"
>
<span class="min-w-0 truncate text-muted-foreground">
{{ target.nodeLabel }}
</span>
<span class="shrink-0 text-muted-foreground">
- {{ target.widgetName }}
</span>
</li>
</ul>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<span class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i
aria-hidden="true"
class="icon-[lucide--circle-question-mark] text-muted-foreground"
/>
<span v-if="!isMissingModelResolution" class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
</div>
</div>
<SingleSelect
v-model="modelValue"
@@ -41,23 +75,37 @@
: $t('assetBrowser.modelTypeSelectorPlaceholder')
"
:options="modelTypes"
:disabled="isLoading"
:disabled="isLoading || isMissingModelResolution"
:content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
<i18n-t
v-if="isMissingModelResolution"
keypath="assetBrowser.missingModelImportTypeLocked"
tag="span"
class="text-muted-foreground"
>
<template #type>
<span>{{ selectedModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
const { uploadContext } = defineProps<{
metadata?: AssetMetadata
previewImage?: string
uploadContext?: UploadModelDialogContext
}>()
const modelValue = defineModel<string | undefined>()
@@ -65,4 +113,27 @@ const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const missingModelName = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.missingModelName
: ''
)
const replacementTargets = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.replacementTargets
: []
)
const selectedModelTypeLabel = computed(() => {
const value =
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: modelValue.value
return (
modelTypes.value.find((option) => option.value === value)?.name ?? value
)
})
</script>

View File

@@ -17,6 +17,7 @@
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
:upload-context="uploadContext"
/>
<!-- Step 3: Upload Progress -->
@@ -24,6 +25,7 @@
v-else-if="currentStep === 3 && uploadStatus != null"
:result="uploadStatus"
:error="uploadError"
:type-mismatch="uploadTypeMismatch"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
@@ -39,6 +41,7 @@
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
:can-import-another="!isMissingModelResolution"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@@ -49,29 +52,47 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const emit = defineEmits<{
'upload-success': []
const { uploadContext } = defineProps<{
uploadContext?: UploadModelDialogContext
}>()
const emit = defineEmits<{
'upload-success': [result: UploadModelSuccess]
}>()
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const requiredModelType = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: undefined
)
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,
canFetchMetadata,
@@ -80,16 +101,18 @@ const {
uploadModel,
goToPreviousStep,
resetWizard
} = useUploadModelWizard(modelTypes)
} = useUploadModelWizard(modelTypes, {
requiredModelType: requiredModelType.value
})
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const success = await uploadModel()
if (success) {
emit('upload-success')
const result = await uploadModel()
if (result) {
emit('upload-success', result)
}
}

View File

@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelFooter from './UploadModelFooter.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function renderFooter(
props: Partial<InstanceType<typeof UploadModelFooter>['$props']> = {}
) {
render(UploadModelFooter, {
props: {
currentStep: 3,
isFetchingMetadata: false,
isUploading: false,
canFetchMetadata: true,
canUploadModel: true,
uploadStatus: 'success',
...props
},
global: {
plugins: [i18n],
stubs: {
VideoHelpDialog: true
}
}
})
}
describe('UploadModelFooter', () => {
it('allows importing another model by default', () => {
renderFooter()
expect(screen.getByRole('button', { name: 'Import Another' })).toBeEnabled()
})
it('disables importing another model when the upload resolves a missing model', () => {
renderFooter({ canImportAnother: false })
expect(
screen.getByRole('button', { name: 'Import Another' })
).toBeDisabled()
})
it('shows recovery actions for upload errors', () => {
renderFooter({ uploadStatus: 'error' })
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
})

View File

@@ -73,6 +73,7 @@
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-import-another-button"
:disabled="!canImportAnother"
@click="emit('importAnother')"
>
{{ $t('assetBrowser.importAnother') }}
@@ -90,6 +91,24 @@
}}
</Button>
</template>
<template v-else-if="currentStep === 3 && uploadStatus === 'error'">
<Button
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-back-button"
@click="emit('back')"
>
{{ $t('g.back') }}
</Button>
<Button
variant="secondary"
size="lg"
data-attr="upload-model-step3-close-button"
@click="emit('close')"
>
{{ $t('g.close') }}
</Button>
</template>
<VideoHelpDialog
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
@@ -113,13 +132,14 @@ import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
const showCivitaiHelp = ref(false)
const showHuggingFaceHelp = ref(false)
defineProps<{
const { canImportAnother = true } = defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus?: 'processing' | 'success' | 'error'
canImportAnother?: boolean
}>()
const emit = defineEmits<{

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelProgress from './UploadModelProgress.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
describe('UploadModelProgress', () => {
it('renders missing-model type mismatch labels', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA/Custom',
requiredModelType: 'Ultralytics/bbox',
requiredModelTypeLabel: 'Ultralytics/bbox'
}
},
global: {
plugins: [i18n]
}
})
expect(
screen.getByText('This model cannot resolve the missing model.')
).toBeInTheDocument()
expect(screen.getByText('LoRA/Custom')).toBeInTheDocument()
expect(screen.getAllByText('Ultralytics/bbox').length).toBeGreaterThan(0)
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Try importing a different Ultralytics/bbox model that this node can use.'
)
})
).toBeInTheDocument()
})
it('uses fallback copy when the imported model type label is unknown', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
}
},
global: {
plugins: [i18n]
}
})
expect(screen.getByText('another model type')).toBeInTheDocument()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'This file is already imported as another model type.'
)
})
).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,12 @@
<template>
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
<div
:class="
cn(
'flex flex-1 flex-col gap-6 text-sm text-muted-foreground',
isTypeMismatchError && 'min-h-full justify-center'
)
"
>
<!-- Processing State (202 async download in progress) -->
<div v-if="result === 'processing'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
@@ -67,8 +74,51 @@
v-else-if="result === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<i
aria-hidden="true"
class="text-error"
:class="
typeMismatch
? 'icon-[lucide--circle-alert] size-12'
: 'icon-[lucide--x-circle] size-16'
"
/>
<div
v-if="typeMismatch"
class="flex max-w-2xl flex-col gap-3 text-center"
>
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.missingModelImportTypeMismatchTitle') }}
</p>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchAlreadyImported"
tag="p"
class="m-0 text-sm text-muted"
>
<template #actual>
<span>{{ actualModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchRequired"
tag="p"
class="m-0 text-sm text-muted"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchNextAction"
tag="p"
class="m-0 text-sm text-base-foreground"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
<div v-else class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
@@ -81,13 +131,26 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { UploadModelTypeMismatch } from '@/platform/assets/composables/useUploadModelWizard'
defineProps<{
const { typeMismatch } = defineProps<{
result: 'processing' | 'success' | 'error'
error?: string
metadata?: AssetMetadata
modelType?: string
previewImage?: string
typeMismatch?: UploadModelTypeMismatch | null
}>()
const { t } = useI18n()
const isTypeMismatchError = computed(() => typeMismatch != null)
const actualModelTypeLabel = computed(
() =>
typeMismatch?.importedModelTypeLabel ??
t('assetBrowser.missingModelImportUnknownType')
)
</script>

View File

@@ -3,17 +3,28 @@ import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
export function useModelUpload(
onUploadSuccess?: () => Promise<unknown> | void
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
) {
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function resolveUploadContext() {
return typeof uploadContext === 'function' ? uploadContext() : uploadContext
}
function showUploadDialog() {
if (!flags.privateModelsEnabled) {
dialogStore.showDialog({
@@ -33,8 +44,9 @@ export function useModelUpload(
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await onUploadSuccess?.()
uploadContext: resolveUploadContext(),
onUploadSuccess: async (result: UploadModelSuccess) => {
await onUploadSuccess?.(result)
}
},
dialogComponentProps: {

View File

@@ -1,14 +1,18 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: vi.fn(),
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
@@ -45,18 +49,52 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toISOString()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('useUploadModelWizard', () => {
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupUploadModelWizard(
...args: Parameters<typeof useUploadModelWizard>
): ReturnType<typeof useUploadModelWizard> {
return setupWithI18n(() => useUploadModelWizard(...args))
}
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
it('updates uploadStatus to success when async download completes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -71,11 +109,18 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'model',
modelType: 'checkpoints',
taskId: 'task-123',
status: 'processing'
})
expect(wizard.uploadStatus.value).toBe('processing')
@@ -118,7 +163,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
wizard.selectedModelType.value = 'checkpoints'
@@ -169,7 +214,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.red/models/12345'
wizard.selectedModelType.value = 'checkpoints'
@@ -178,4 +223,160 @@ describe('useUploadModelWizard', () => {
expect(assetService.uploadAssetAsync).toHaveBeenCalled()
expect(wizard.uploadStatus.value).toBe('processing')
})
it('keeps a required model type when metadata suggests another type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'lora.safetensors',
tags: ['loras']
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('uploads with the required model type even if selection changes', 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', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'checkpoints'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'loras'
const result = await wizard.uploadModel()
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({
tags: ['models', 'checkpoints'],
user_metadata: expect.objectContaining({
model_type: 'checkpoints'
})
})
)
expect(result?.modelType).toBe('checkpoints')
})
it('returns the synced asset filename for sync imports', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-canonical',
name: 'asset-record-display-name.safetensors',
tags: ['models', 'checkpoints'],
user_metadata: {
filename: 'models/checkpoints/canonical-model.safetensors'
}
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url:
'https://civitai.com/api/download/models/canonical-model.safetensors',
filename: 'metadata-model.safetensors',
tags: ['checkpoints']
}
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'models/checkpoints/canonical-model.safetensors',
modelType: 'checkpoints',
status: 'success'
})
})
it('blocks a missing-model import when an existing asset has the wrong model type', 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', '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.uploadStatus.value).toBe('error')
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')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
])
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual(
expect.objectContaining({
modelType: 'checkpoints',
status: 'success'
})
)
expect(wizard.uploadStatus.value).toBe('success')
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
})

View File

@@ -5,9 +5,13 @@ import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -26,16 +30,54 @@ interface ModelTypeOption {
value: string
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const MODEL_ROOT_TAG = 'models'
export interface UploadModelSuccess {
filename: string
modelType?: string
taskId?: string
status: 'processing' | 'success'
}
export interface UploadModelTypeMismatch {
importedModelType?: string
importedModelTypeLabel?: string
requiredModelType: string
requiredModelTypeLabel: string
}
interface MissingModelUploadContext {
kind: 'missing-model-resolution'
missingModelName: string
requiredModelType: string
replacementTargets: Array<{
nodeId: string
nodeLabel: string
widgetName: string
}>
}
export type UploadModelDialogContext = MissingModelUploadContext
interface UploadModelWizardOptions {
requiredModelType?: string
}
export function useUploadModelWizard(
modelTypes: Ref<ModelTypeOption[]>,
options: UploadModelWizardOptions = {}
) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const requiredModelType = options.requiredModelType
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
const uploadTypeMismatch = ref<UploadModelTypeMismatch | null>(null)
let stopAsyncWatch: (() => void) | undefined
const wizardData = ref<WizardData>({
@@ -44,7 +86,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
tags: []
})
const selectedModelType = ref<string>()
const selectedModelType = ref<string | undefined>(requiredModelType)
const resolvedModelType = computed(
() => requiredModelType ?? selectedModelType.value
)
const importSources: ImportSource[] = [
civitaiImportSource,
@@ -65,16 +110,29 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
() => wizardData.value.url,
() => {
uploadError.value = ''
uploadTypeMismatch.value = null
}
)
if (requiredModelType) {
watch(
selectedModelType,
(value) => {
if (value !== requiredModelType) {
selectedModelType.value = requiredModelType
}
},
{ immediate: true }
)
}
// Validation - only enable Continue when URL matches a supported source
const canFetchMetadata = computed(() => {
return detectedSource.value !== null
})
const canUploadModel = computed(() => {
return !!selectedModelType.value
return !!resolvedModelType.value
})
async function fetchMetadata() {
@@ -128,7 +186,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
if (requiredModelType) {
selectedModelType.value = requiredModelType
} else if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
// Try to detect model type from tags
const typeTag = metadata.tags.find((tag) =>
@@ -183,10 +243,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
async function refreshModelCaches() {
if (!selectedModelType.value) return
if (!resolvedModelType.value) return
const providers = modelToNodeStore.getAllNodeProviders(
selectedModelType.value
resolvedModelType.value
)
const results = await Promise.allSettled(
providers.map((provider) =>
@@ -203,24 +263,61 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
})
}
async function uploadModel(): Promise<boolean> {
if (isUploading.value) return false
function getModelTypeLabel(modelType: string): string {
return (
modelTypes.value.find((type) => type.value === modelType)?.name ??
modelType
)
}
function getImportedModelType(asset: AssetItem): string | undefined {
const knownType = asset.tags.find(
(tag) =>
tag !== MODEL_ROOT_TAG &&
modelTypes.value.some((type) => type.value === tag)
)
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
}
function blockMismatchedImportedModel(
asset: AssetItem,
requiredType: string
): boolean {
if (asset.tags.includes(requiredType)) return false
const importedType = getImportedModelType(asset)
uploadStatus.value = 'error'
uploadError.value = ''
uploadTypeMismatch.value = {
importedModelType: importedType,
importedModelTypeLabel: importedType
? getModelTypeLabel(importedType)
: undefined,
requiredModelType: requiredType,
requiredModelTypeLabel: getModelTypeLabel(requiredType)
}
return true
}
async function uploadModel(): Promise<UploadModelSuccess | null> {
if (isUploading.value) return null
if (!canUploadModel.value) {
return false
return null
}
const source = detectedSource.value
if (!source) {
uploadError.value = t('assetBrowser.noValidSourceDetected')
return false
return null
}
isUploading.value = true
uploadTypeMismatch.value = null
let uploadSuccess: UploadModelSuccess | null = null
try {
const tags = selectedModelType.value
? ['models', selectedModelType.value]
: ['models']
const modelType = resolvedModelType.value
const tags = modelType ? ['models', modelType] : ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||
@@ -230,7 +327,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const userMetadata = {
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
model_type: modelType
}
const result = await assetService.uploadAssetAsync({
@@ -241,14 +338,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
})
if (result.type === 'async' && result.task.status !== 'completed') {
if (selectedModelType.value) {
if (modelType) {
assetDownloadStore.trackDownload(
result.task.task_id,
selectedModelType.value,
modelType,
filename
)
}
uploadStatus.value = 'processing'
uploadSuccess = {
filename,
modelType,
taskId: result.task.task_id,
status: 'processing'
}
stopAsyncWatch?.()
let resolved = false
@@ -288,8 +391,24 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
stopAsyncWatch = stop
}
} else {
if (
requiredModelType &&
result.type === 'sync' &&
modelType &&
blockMismatchedImportedModel(result.asset, modelType)
) {
currentStep.value = 3
return null
}
uploadStatus.value = 'success'
await refreshModelCaches()
uploadSuccess = {
filename:
result.type === 'sync' ? getAssetFilename(result.asset) : filename,
modelType,
status: 'success'
}
}
currentStep.value = 3
} catch (error) {
@@ -301,7 +420,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
} finally {
isUploading.value = false
}
return uploadStatus.value !== 'error'
return uploadSuccess
}
function goToPreviousStep() {
@@ -318,12 +437,13 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
isUploading.value = false
uploadStatus.value = undefined
uploadError.value = ''
uploadTypeMismatch.value = null
wizardData.value = {
url: '',
name: '',
tags: []
}
selectedModelType.value = undefined
selectedModelType.value = requiredModelType
}
return {
@@ -333,6 +453,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,

View File

@@ -141,7 +141,7 @@ async function setupMocks(
const {
nodeProvider = createMockNodeProvider(),
canvasCenter = [100, 200],
activeSubgraph = undefined,
activeSubgraph,
createdNode = await createMockNode()
} = overrides

View File

@@ -1,24 +1,37 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template:
'<div class="model-row" :data-show-node-id-badge="showNodeIdBadge" :data-is-asset-supported="isAssetSupported" :data-directory="directory"><button class="locate-trigger" @click="$emit(\'locate-model\', model?.representative?.nodeId)">Locate</button></div>',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
template: `
<div
data-testid="model-row"
class="model-row"
:data-model-name="model.name"
:data-is-asset-supported="isAssetSupported"
:data-directory="directory"
:data-can-cloud-import="canCloudImport"
>
<button
class="locate-trigger"
@click="$emit('locate-model', model?.representative?.nodeId)"
>
Locate
</button>
</div>
`,
props: ['model', 'directory', 'isAssetSupported', 'canCloudImport'],
emits: ['locate-model']
}
}))
@@ -35,21 +48,7 @@ import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category',
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
}
}
}
},
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
@@ -106,7 +105,6 @@ function makeGroup(
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {},
onLocateModel?: (nodeId: string) => void
) {
@@ -114,7 +112,6 @@ function mountCard(
return render(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props,
...(onLocateModel ? { onLocateModel } : {})
},
@@ -124,62 +121,115 @@ function mountCard(
})
}
function getRows() {
return screen.queryAllByTestId('model-row')
}
function getRowsIn(testId: string) {
return within(screen.getByTestId(testId)).getAllByTestId('model-row')
}
describe('MissingModelCard', () => {
beforeEach(() => {
mockIsCloud.value = true
})
describe('Rendering & Props', () => {
it('renders directory name in category header', () => {
const { container } = mountCard({
it('passes the model directory to rows', () => {
mockIsCloud.value = false
mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(container.textContent).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(container.textContent).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const { container } = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(container.textContent).toContain('(2)')
expect(getRows()[0].getAttribute('data-directory')).toBe('loras')
})
it('renders correct number of MissingModelRow components', () => {
const { container } = mountCard({
mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(3)
expect(getRows()).toHaveLength(3)
})
it('renders multiple groups', () => {
const { container } = mountCard({
it('flattens multiple groups into rows', () => {
mockIsCloud.value = false
mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).toContain('loras')
expect(getRows()).toHaveLength(2)
})
it('sorts importable rows by model type order in cloud', () => {
mountCard({
missingModelGroups: [
makeGroup({ directory: null, modelNames: ['unknown.safetensors'] }),
makeGroup({ directory: 'loras', modelNames: ['lora.safetensors'] }),
makeGroup({
directory: 'checkpoints',
modelNames: ['checkpoint.safetensors']
})
]
})
expect(
getRowsIn('missing-model-importable-rows').map((row) =>
row.getAttribute('data-model-name')
)
).toEqual(['checkpoint.safetensors', 'lora.safetensors'])
})
it('moves cloud rows without import context into the unsupported section', () => {
mountCard({
missingModelGroups: [
makeGroup({
directory: 'checkpoints',
modelNames: ['importable.safetensors']
}),
makeGroup({
directory: null,
modelNames: ['unknown.safetensors']
}),
makeGroup({
directory: 'loras',
isAssetSupported: false,
modelNames: ['custom-node-model.safetensors']
})
]
})
expect(
getRowsIn('missing-model-importable-rows').map((row) =>
row.getAttribute('data-model-name')
)
).toEqual(['importable.safetensors'])
const unsupportedSection = screen.getByTestId(
'missing-model-import-not-supported-section'
)
expect(
within(unsupportedSection)
.getAllByTestId('model-row')
.map((row) => row.getAttribute('data-model-name'))
).toEqual(['custom-node-model.safetensors', 'unknown.safetensors'])
expect(
within(unsupportedSection).getByText('Import Not Supported')
).toBeInTheDocument()
expect(
within(unsupportedSection).getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeInTheDocument()
})
it('renders zero rows when missingModelGroups is empty', () => {
const { container } = mountCard({ missingModelGroups: [] })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(0)
mountCard({ missingModelGroups: [] })
expect(getRows()).toHaveLength(0)
})
it('hides bulk actions in cloud', () => {
@@ -191,43 +241,6 @@ describe('MissingModelCard', () => {
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
it('passes props correctly to MissingModelRow children', () => {
const { container } = mountCard({ showNodeIdBadge: true })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const row = container.querySelector('.model-row')
expect(row).not.toBeNull()
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
expect(row!.getAttribute('data-is-asset-supported')).toBe('true')
expect(row!.getAttribute('data-directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain(
'Cloud environment does not support model imports'
)
})
it('hides info notice for supported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: true })]
})
expect(container.textContent).not.toContain(
'Cloud environment does not support model imports'
)
})
})
describe('Event Handling', () => {
@@ -251,79 +264,43 @@ describe('MissingModelCard (OSS)', () => {
})
it('shows directory name instead of "Import Not Supported" for unsupported groups', () => {
const { container } = mountCard({
mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints', isAssetSupported: false })
]
})
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).not.toContain('Import Not Supported')
expect(getRows()[0].getAttribute('data-directory')).toBe('checkpoints')
})
it('hides info notice for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).not.toContain(
'Cloud environment does not support model imports'
)
})
it('renders unknown category for null directory in OSS', () => {
const { container } = mountCard({
it('passes null directory for unknown category rows in OSS', () => {
mountCard({
missingModelGroups: [
makeGroup({ directory: null, isAssetSupported: false })
]
})
expect(container.textContent).toContain('Unknown Category')
expect(container.textContent).not.toContain('Import Not Supported')
expect(getRows()[0].hasAttribute('data-directory')).toBe(false)
})
it('shows bulk actions when one model is downloadable', () => {
it('shows Download all at the bottom when one model is downloadable', () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
const actions = screen.getByTestId('missing-model-actions')
expect(actions).toBeVisible()
expect(
within(actions).getByRole('button', { name: /Download all/ })
).toBeVisible()
})
it('hides bulk actions when no model is downloadable', () => {
it('hides Download all when no model is downloadable', () => {
mountCard()
expect(
screen.queryByRole('button', { name: /Download all/ })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Refresh' })
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
it('refreshes missing models from the action bar', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
expect(store.refreshMissingModels).toHaveBeenCalled()
})
it('keeps the Refresh button focusable and announces refresh progress', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
store.isRefreshingMissingModels = true
await nextTick()
const refreshButton = screen.getByRole('button', { name: 'Refresh' })
expect(refreshButton).toHaveAttribute('aria-disabled', 'true')
expect(refreshButton).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('status')).toHaveTextContent(
'Refreshing missing models.'
)
})
})

View File

@@ -1,9 +1,49 @@
<template>
<div class="px-4 pb-2">
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden py-2"
>
<MissingModelRow
v-for="row in importableModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="true"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="unsupportedModelRows.length > 0"
data-testid="missing-model-import-not-supported-section"
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
>
<div class="mb-1">
<p class="m-0 text-sm font-semibold text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
</p>
<p class="m-0 mt-1 text-xs/relaxed text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</p>
</div>
<MissingModelRow
v-for="row in unsupportedModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="false"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="downloadableModels.length > 0"
data-testid="missing-model-actions"
class="flex items-center gap-2 border-b border-interface-stroke py-2"
class="flex items-center pt-2"
>
<Button
data-testid="missing-model-download-all"
@@ -15,100 +55,6 @@
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
<span class="truncate">{{ downloadAllLabel }}</span>
</Button>
<!-- Keep this focusable while refreshing so the live status remains discoverable. -->
<Button
data-testid="missing-model-refresh"
variant="secondary"
size="sm"
class="h-8 w-28 shrink-0 rounded-lg text-sm"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click="handleRefreshClick"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span role="status" aria-live="polite" class="sr-only">
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</div>
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
(isCloud && !group.isAssetSupported) || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="isCloud && !group.isAssetSupported">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
<i
v-if="group.directory === null"
aria-hidden="true"
class="mr-1 icon-[lucide--triangle-alert] size-3.5 align-text-bottom"
/>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="isCloud && !group.isAssetSupported"
data-testid="missing-model-import-unsupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
@@ -120,15 +66,28 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isCloud } from '@/platform/distribution/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
const { missingModelGroups, showNodeIdBadge } = defineProps<{
interface MissingModelRowEntry {
key: string
model: MissingModelGroup['models'][number]
directory: string | null
isAssetSupported: boolean
}
const MODEL_TYPE_SORT_ORDER = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const { missingModelGroups } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -138,6 +97,27 @@ const emit = defineEmits<{
const { t } = useI18n()
const missingModelStore = useMissingModelStore()
const sortedModelRows = computed(() =>
missingModelGroups
.flatMap((group) =>
group.models.map((model, index) => ({
key: getModelRowKey(group, model, index),
model,
directory: group.directory,
isAssetSupported: group.isAssetSupported
}))
)
.sort((a, b) => compareModelRows(a, b))
)
const importableModelRows = computed(() =>
sortedModelRows.value.filter((row) => !isCloud || canCloudImport(row))
)
const unsupportedModelRows = computed(() =>
isCloud ? sortedModelRows.value.filter((row) => !canCloudImport(row)) : []
)
const downloadableModels = computed(() => {
if (isCloud) return []
@@ -159,7 +139,37 @@ function downloadAllModels() {
}
}
function handleRefreshClick() {
void missingModelStore.refreshMissingModels()
function getModelRowKey(
group: MissingModelGroup,
model: MissingModelGroup['models'][number],
index: number
) {
const supportKey = group.isAssetSupported ? 'supported' : 'unsupported'
return [
supportKey,
group.directory ?? '__unknown__',
model.name,
String(index)
].join('::')
}
function compareModelRows(a: MissingModelRowEntry, b: MissingModelRowEntry) {
return (
getModelTypeSortIndex(a.directory) - getModelTypeSortIndex(b.directory) ||
(a.directory ?? '').localeCompare(b.directory ?? '') ||
a.model.name.localeCompare(b.model.name)
)
}
function getModelTypeSortIndex(directory: string | null) {
if (directory === null) return Number.MAX_SAFE_INTEGER
const index = MODEL_TYPE_SORT_ORDER.indexOf(
directory as (typeof MODEL_TYPE_SORT_ORDER)[number]
)
return index === -1 ? MODEL_TYPE_SORT_ORDER.length : index
}
function canCloudImport(row: MissingModelRowEntry) {
return row.isAssetSupported && row.directory !== null
}
</script>

View File

@@ -1,113 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -0,0 +1,460 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
const mockDownloadModel = vi.hoisted(() => vi.fn())
const mockRootGraph = vi.hoisted<{
value: Record<string, never> | null
}>(() => ({ value: null }))
const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
const mockApiListeners = vi.hoisted(
() => new Map<string, (event: CustomEvent) => void>()
)
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
const mockUploadContext = vi.hoisted(() => ({
resolver: undefined as UploadModelContextResolver | undefined
}))
const mockUploadCallbacks = vi.hoisted(() => ({
onUploadSuccess: undefined as
| ((result: UploadModelSuccess) => Promise<unknown> | unknown)
| undefined
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mockRootGraph.value
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(
(event: string, handler: (event: CustomEvent) => void) => {
mockApiListeners.set(event, handler)
}
),
apiURL: vi.fn((path: string) => path),
fetchApi: vi.fn()
}
}))
vi.mock('@/utils/graphTraversalUtil', async () => {
const actual = await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
return {
...actual,
getActiveGraphNodeIds: vi.fn(() => new Set()),
getNodeByExecutionId: mockGetNodeByExecutionId
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
useModelUpload: (
onUploadSuccess?: (
result: UploadModelSuccess
) => Promise<unknown> | unknown,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
) => {
mockUploadCallbacks.onUploadSuccess = onUploadSuccess
mockUploadContext.resolver =
typeof uploadContext === 'function' ? uploadContext : () => uploadContext
return {
isUploadButtonEnabled: { value: true },
showUploadDialog: mockShowUploadDialog
}
}
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard
})
}))
vi.mock('@/platform/missingModel/missingModelDownload', async () => {
const actual = await vi.importActual<typeof MissingModelDownload>(
'@/platform/missingModel/missingModelDownload'
)
return {
...actual,
downloadModel: mockDownloadModel,
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
})
}
})
import MissingModelRow from './MissingModelRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
const TransitionCollapseStub = {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
function makeModel(
refs: MissingModelViewModel['referencingNodes']
): MissingModelViewModel {
return {
name: 'model.safetensors',
representative: {
nodeId: refs[0]?.nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'model.safetensors',
directory: 'checkpoints',
url: 'https://example.com/model.safetensors',
isMissing: true
},
referencingNodes: refs
}
}
function renderRow(
model: MissingModelViewModel,
onLocateModel = vi.fn(),
isAssetSupported = true,
directory: string | null = 'checkpoints',
canCloudImport = true
) {
const pinia = createPinia()
setActivePinia(pinia)
render(MissingModelRow, {
props: {
model,
directory,
isAssetSupported,
canCloudImport,
onLocateModel
},
global: {
plugins: [pinia, i18n],
stubs: {
TransitionCollapse: TransitionCollapseStub
}
}
})
return { onLocateModel }
}
describe('MissingModelRow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockRootGraph.value = null
mockApiListeners.clear()
mockGetNodeByExecutionId.mockReset()
mockUploadContext.resolver = undefined
mockUploadCallbacks.onUploadSuccess = undefined
})
it('opens the model import dialog from the cloud row', async () => {
const user = userEvent.setup()
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
await user.click(screen.getByRole('button', { name: 'Import' }))
expect(mockShowUploadDialog).toHaveBeenCalledTimes(1)
expect(mockUploadContext.resolver?.()).toEqual({
kind: 'missing-model-resolution',
missingModelName: 'model.safetensors',
requiredModelType: 'checkpoints',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name'
}
]
})
})
it('keeps unsupported cloud rows as reference-only rows', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'model_name' }]),
vi.fn(),
true,
null,
false
)
expect(screen.getByText('model.safetensors')).toBeInTheDocument()
expect(screen.getByText('Unknown')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'CheckpointLoaderSimple' })
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
})
it('shows row progress as soon as the model import starts', async () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
const store = useMissingModelStore()
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'downloaded-model.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
expect(
store.importTaskIds['supported::checkpoints::model.safetensors']
).toBe('task-1')
expect(
screen.getByRole('progressbar', { name: 'Importing...' })
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Importing...')
expect(screen.getByText('downloaded-model.safetensors')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
})
it('applies the completed imported model to every referencing node', async () => {
const graph = {}
const firstWidget = {
name: 'ckpt_name',
value: 'old-first.safetensors',
callback: vi.fn()
}
const secondWidget = {
name: 'ckpt_name',
value: 'old-second.safetensors',
callback: vi.fn()
}
const firstSetDirtyCanvas = vi.fn()
const secondSetDirtyCanvas = vi.fn()
mockRootGraph.value = graph
mockGetNodeByExecutionId.mockImplementation((_graph, nodeId) => {
if (nodeId === '1') {
return {
widgets: [firstWidget],
graph: { setDirtyCanvas: firstSetDirtyCanvas }
}
}
if (nodeId === '2') {
return {
widgets: [secondWidget],
graph: { setDirtyCanvas: secondSetDirtyCanvas }
}
}
return null
})
renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'client-name.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
const handler = mockApiListeners.get('asset_download')
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-1',
asset_name: 'server-name.safetensors',
bytes_total: 100,
bytes_downloaded: 100,
progress: 1,
status: 'completed'
}
})
)
await waitFor(() => {
expect(firstWidget.value).toBe('server-name.safetensors')
expect(secondWidget.value).toBe('server-name.safetensors')
})
expect(firstWidget.callback).toHaveBeenCalledWith('server-name.safetensors')
expect(secondWidget.callback).toHaveBeenCalledWith(
'server-name.safetensors'
)
expect(firstSetDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(secondSetDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('locates the parent row directly when a cloud model has one reference', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('moves locate actions to expanded child rows when a cloud model has multiple references', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('locates the parent row directly when an OSS model has one reference', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('shows no resolution action in OSS rows without a download url', () => {
mockIsCloud.value = false
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-download')
).not.toBeInTheDocument()
expect(screen.queryByTestId('missing-model-import')).not.toBeInTheDocument()
})
it('shows model type metadata below the model name', () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(screen.getByText('checkpoints')).toBeInTheDocument()
})
it('shows downloadable model size beside the model type metadata', async () => {
mockIsCloud.value = false
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
const store = useMissingModelStore()
store.fileSizes[model.representative.url] = 14 * 1024 ** 3
await nextTick()
expect(screen.getByText('checkpoints · 14 GB')).toBeInTheDocument()
expect(screen.getByTestId('missing-model-download')).toHaveTextContent(
'Download'
)
})
it('shows unknown category metadata for models without a directory', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]),
vi.fn(),
true,
null
)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
it('moves locate actions to expanded child rows when an OSS model has multiple references', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('shows the OSS download action in the row for downloadable models', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
await user.click(screen.getByTestId('missing-model-download'))
expect(mockDownloadModel).toHaveBeenCalledWith(
{
name: 'model.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{}
)
})
})

View File

@@ -1,72 +1,11 @@
<template>
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
data-testid="missing-model-copy-name"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="!isCloud && model.representative.url && !isAssetSupported"
data-testid="missing-model-copy-url"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="copyToClipboard(toBrowsableUrl(model.representative.url!))"
>
{{ t('rightSidePanel.missingModels.copyUrl') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
v-if="hasMultipleReferences"
data-testid="missing-model-expand"
variant="textonly"
size="icon-sm"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
@@ -75,131 +14,193 @@
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleModelExpand(modelKey)"
@click="handleToggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="block min-w-0 text-sm/tight">
<button
v-if="hasModelLabelControl"
ref="modelLabelControl"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
:title="displayModelName"
@click="handleModelLabelClick"
>
{{ displayModelName }}
</button>
<span
v-else
class="font-normal wrap-break-word text-base-foreground"
:title="displayModelName"
>
{{ displayModelName }}
</span>
<span
v-if="hasMultipleReferences"
data-testid="missing-model-reference-count"
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
>
{{ model.referencingNodes.length }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="linkLabel"
:title="linkLabel"
@click="copyModelLink"
>
<i aria-hidden="true" class="icon-[lucide--link] size-4" />
</Button>
</span>
<span
v-if="modelMetadataLabel"
class="block text-2xs/tight"
:class="
isUnknownCategory
? 'text-warning-background'
: 'text-muted-foreground'
"
>
{{ modelMetadataLabel }}
</span>
</span>
<template v-if="isCloud && canCloudImport">
<Button
v-if="!isCloudImportDownloadActive"
data-testid="missing-model-import"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="showUploadDialog"
>
{{ t('g.import') }}
</Button>
<div
v-else
ref="cloudProgress"
role="progressbar"
:aria-label="t('rightSidePanel.missingModels.importing')"
:aria-valuenow="cloudImportProgressPercent"
aria-valuemin="0"
aria-valuemax="100"
tabindex="-1"
class="flex h-8 w-16 shrink-0 items-center"
>
<span
class="block h-1.5 w-full overflow-hidden rounded-full bg-secondary-background-selected"
>
<span
class="block h-full rounded-full bg-primary-background transition-all duration-200 ease-linear"
:style="{ width: `${cloudImportProgressPercent}%` }"
/>
</span>
</div>
<span
v-if="isCloudImportDownloadActive"
role="status"
aria-live="polite"
class="sr-only"
>
{{ t('rightSidePanel.missingModels.importing') }}
</span>
</template>
<template v-else>
<Button
v-if="showDownloadAction"
data-testid="missing-model-download"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
{{ t('g.download') }}
</Button>
</template>
<Button
v-if="!hasMultipleReferences && !isUnknownCategory && primaryReference"
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="handleLocatePrimary"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
<ul
v-if="showReferenceList"
:class="
cn(
'm-0 list-none space-y-0.5 p-0',
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
)
"
>
<div
<li
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
class="min-w-0"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-1"
>
<div v-if="isAssetSupported" class="flex w-full flex-col py-1">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</div>
<div
v-else-if="!isCloud && downloadable"
class="flex w-full items-start py-1"
>
<Button
data-testid="missing-model-download"
variant="secondary"
size="md"
class="flex w-full flex-1"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
<i
aria-hidden="true"
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{ downloadLabel }}
</span>
</Button>
</div>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="isAssetSupported || downloadable"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
<div class="flex min-h-6 min-w-0 items-center gap-2">
<button
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="emit('locateModel', String(ref.nodeId))"
>
{{
getNodeDisplayLabel(ref.nodeId, model.representative.nodeType)
}}
</button>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel,
getComboValue
getNodeDisplayLabel
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
@@ -212,11 +213,16 @@ import {
} from '@/platform/missingModel/missingModelDownload'
import { formatSize } from '@/utils/formatUtil'
const { model, directory, isAssetSupported } = defineProps<{
const {
model,
directory,
isAssetSupported,
canCloudImport = true
} = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
canCloudImport?: boolean
}>()
const emit = defineEmits<{
@@ -231,21 +237,117 @@ const modelKey = computed(() =>
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isUnknownCategory = computed(() => directory === null)
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const isCloudImportDownloadActive = computed(
() => isCloud && canCloudImport && isDownloadActive.value
)
const cloudImportProgressPercent = computed(() =>
Math.round((downloadStatus.value?.progress ?? 0) * 100)
)
const hasMultipleReferences = computed(() => model.referencingNodes.length > 1)
const primaryReference = computed(() => model.referencingNodes[0])
const hasModelLabelControl = computed(
() =>
hasMultipleReferences.value ||
(!isUnknownCategory.value && !!primaryReference.value)
)
const linkLabel = computed(() =>
model.representative.url
? t('rightSidePanel.missingModels.copyUrl')
: t('rightSidePanel.missingModels.copyModelName')
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const { selectedLibraryModel } = storeToRefs(store)
const cloudProgress = useTemplateRef<HTMLElement>('cloudProgress')
const modelLabelControl = useTemplateRef<HTMLButtonElement>('modelLabelControl')
const expanded = computed(
() =>
store.modelExpandState[modelKey.value] ??
(isUnknownCategory.value && hasMultipleReferences.value)
)
const showReferenceList = computed(
() =>
(isUnknownCategory.value && model.referencingNodes.length === 1) ||
(hasMultipleReferences.value && expanded.value)
)
const displayModelName = computed(() => {
if (!isCloudImportDownloadActive.value) return model.name
return (
downloadStatus.value?.assetName ??
selectedLibraryModel.value[modelKey.value] ??
model.name
)
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const showDownloadAction = computed(() => !isCloud && downloadable.value)
const downloadSizeLabel = computed(() => {
if (!showDownloadAction.value) return undefined
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? formatSize(size) : undefined
})
const modelTypeLabel = computed(
() => directory ?? t('rightSidePanel.missingModels.unknownCategory')
)
const modelMetadataLabel = computed(() =>
[modelTypeLabel.value, downloadSizeLabel.value].filter(Boolean).join(' · ')
)
const missingModelUploadContext = computed<
UploadModelDialogContext | undefined
>(() => {
if (!canCloudImport || !directory) return undefined
return {
kind: 'missing-model-resolution',
missingModelName: model.name,
requiredModelType: directory,
replacementTargets: model.referencingNodes.map((ref) => ({
nodeId: String(ref.nodeId),
nodeLabel: getNodeDisplayLabel(ref.nodeId, model.representative.nodeType),
widgetName: ref.widgetName
}))
}
})
const { showUploadDialog } = useModelUpload(
(result) => {
handleUploadedModelImport(modelKey.value, result)
if (result.status === 'success') {
handleLibrarySelect()
}
},
() => missingModelUploadContext.value
)
onMounted(() => {
if (isCloud) return
const url = model.representative.url
if (url && !store.fileSizes[url]) {
fetchModelMetadata(url)
@@ -263,27 +365,6 @@ onMounted(() => {
}
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
!isAssetSupported &&
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const downloadLabel = computed(() => {
const base = t('g.download')
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? `${base} (${formatSize(size)})` : base
})
function handleDownload() {
const rep = model.representative
if (rep.url && rep.directory) {
@@ -296,17 +377,53 @@ function handleDownload() {
}
}
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus
} = useMissingModelInteractions()
function handleLocatePrimary() {
const ref = primaryReference.value
if (ref) emit('locateModel', String(ref.nodeId))
}
function copyModelLink() {
const url = model.representative.url
copyToClipboard(url ? toBrowsableUrl(url) : model.name)
}
const { confirmLibrarySelect, getDownloadStatus, handleUploadedModelImport } =
useMissingModelInteractions()
function handleToggleExpand() {
store.modelExpandState[modelKey.value] = !expanded.value
}
function handleModelLabelClick() {
if (hasMultipleReferences.value) {
handleToggleExpand()
return
}
handleLocatePrimary()
}
watch(
() => downloadStatus.value?.status,
(status) => {
if (!isCloud || status !== 'completed') return
const completedAssetName = downloadStatus.value?.assetName
if (completedAssetName) {
selectedLibraryModel.value[modelKey.value] = completedAssetName
}
handleLibrarySelect()
},
{ immediate: true }
)
watch(isCloudImportDownloadActive, async (isActive, wasActive) => {
await nextTick()
if (isActive) {
cloudProgress.value?.focus()
} else if (wasActive) {
modelLabelControl.value?.focus()
}
})
function handleLibrarySelect() {
confirmLibrarySelect(

View File

@@ -1,108 +0,0 @@
<template>
<div
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
/>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="categoryMismatch"
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
/>
<i
v-else-if="downloadStatus?.status === 'failed'"
aria-hidden="true"
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'completed'"
aria-hidden="true"
class="icon-[lucide--check-circle] size-5 text-success-background"
/>
<i
v-else-if="isDownloadActive"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ modelName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="categoryMismatch">
{{
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
category: categoryMismatch
})
}}
</template>
<template v-else-if="isDownloadActive">
{{ t('rightSidePanel.missingModels.importing') }}
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
</template>
<template v-else-if="downloadStatus?.status === 'completed'">
{{ t('rightSidePanel.missingModels.imported') }}
</template>
<template v-else-if="downloadStatus?.status === 'failed'">
{{
downloadStatus?.error ||
t('rightSidePanel.missingModels.importFailed')
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
</template>
</span>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('cancel')"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
const {
modelName,
isDownloadActive,
downloadStatus = null,
categoryMismatch = null
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
categoryMismatch?: string | null
}>()
const emit = defineEmits<{
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -1,184 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockPrivateModelsEnabled = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockHandleUrlInput = vi.hoisted(() => vi.fn())
const mockHandleImport = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get privateModelsEnabled() {
return mockPrivateModelsEnabled.value
}
}
})
}))
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
useModelUpload: () => ({
isUploadButtonEnabled: { value: true },
showUploadDialog: mockShowUploadDialog
})
}))
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
useMissingModelInteractions: () => ({
handleUrlInput: mockHandleUrlInput,
handleImport: mockHandleImport
})
})
)
vi.mock('@/components/rightSidePanel/layout/TransitionCollapse.vue', () => ({
default: {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
}))
import MissingModelUrlInput from './MissingModelUrlInput.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading' },
rightSidePanel: {
missingModels: {
urlPlaceholder: 'Paste model URL...',
clearUrl: 'Clear URL',
import: 'Import',
importAnyway: 'Import Anyway',
typeMismatch: 'Type mismatch: {detectedType}',
unsupportedUrl: 'Unsupported URL',
metadataFetchFailed: 'Failed to fetch metadata',
importFailed: 'Import failed'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
const MODEL_KEY = 'supported::checkpoints::model.safetensors'
function renderComponent(
props: Partial<{
modelKey: string
directory: string | null
typeMismatch: string | null
}> = {}
) {
return render(MissingModelUrlInput, {
props: {
modelKey: MODEL_KEY,
directory: 'checkpoints',
typeMismatch: null,
...props
},
global: {
plugins: [i18n]
}
})
}
describe('MissingModelUrlInput', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockPrivateModelsEnabled.value = true
mockShowUploadDialog.mockClear()
mockHandleUrlInput.mockClear()
mockHandleImport.mockClear()
})
describe('URL input is always editable', () => {
it('input is editable when privateModelsEnabled is true', () => {
mockPrivateModelsEnabled.value = true
renderComponent()
const input = screen.getByRole('textbox')
expect(input).not.toHaveAttribute('readonly')
})
it('input is editable when privateModelsEnabled is false (free tier)', () => {
mockPrivateModelsEnabled.value = false
renderComponent()
const input = screen.getByRole('textbox')
expect(input).not.toHaveAttribute('readonly')
})
it('input accepts user typing when privateModelsEnabled is false', async () => {
mockPrivateModelsEnabled.value = false
renderComponent()
const input = screen.getByRole('textbox') as HTMLInputElement
input.value = 'https://example.com/model.safetensors'
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.input(input)
expect(mockHandleUrlInput).toHaveBeenCalledWith(
MODEL_KEY,
'https://example.com/model.safetensors'
)
})
})
describe('Import button gates on subscription', () => {
it('calls handleImport when privateModelsEnabled is true', async () => {
mockPrivateModelsEnabled.value = true
const user = userEvent.setup()
const store = useMissingModelStore()
store.urlMetadata[MODEL_KEY] = {
filename: 'model.safetensors',
content_length: 1024,
final_url: 'https://example.com/model.safetensors'
}
renderComponent()
const importBtn = screen.getByRole('button', { name: /Import/ })
expect(importBtn).toBeInTheDocument()
await user.click(importBtn)
expect(mockHandleImport).toHaveBeenCalledWith(MODEL_KEY, 'checkpoints')
expect(mockShowUploadDialog).not.toHaveBeenCalled()
})
it('calls showUploadDialog when privateModelsEnabled is false (free tier)', async () => {
mockPrivateModelsEnabled.value = false
const user = userEvent.setup()
const store = useMissingModelStore()
store.urlMetadata[MODEL_KEY] = {
filename: 'model.safetensors',
content_length: 1024,
final_url: 'https://example.com/model.safetensors'
}
renderComponent()
const importBtn = screen.getByRole('button', { name: /Import/ })
expect(importBtn).toBeInTheDocument()
await user.click(importBtn)
expect(mockShowUploadDialog).toHaveBeenCalled()
expect(mockHandleImport).not.toHaveBeenCalled()
})
it('clear button works for free-tier users', async () => {
mockPrivateModelsEnabled.value = false
const user = userEvent.setup()
const store = useMissingModelStore()
store.urlInputs[MODEL_KEY] = 'https://example.com/model.safetensors'
renderComponent()
const clearBtn = screen.getByRole('button', { name: 'Clear URL' })
await user.click(clearBtn)
expect(mockHandleUrlInput).toHaveBeenCalledWith(MODEL_KEY, '')
})
})
})

View File

@@ -1,135 +0,0 @@
<template>
<div
class="flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke"
>
<label :for="`url-input-${modelKey}`" class="sr-only">
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
</label>
<input
:id="`url-input-${modelKey}`"
type="text"
:value="urlInputs[modelKey] ?? ''"
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
class="text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
@input="
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
"
/>
<Button
v-if="urlInputs[modelKey]"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
class="ml-1 shrink-0"
@click.stop="handleUrlInput(modelKey, '')"
>
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<TransitionCollapse>
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5 pt-2.5">
<span class="text-foreground min-w-0 truncate text-xs font-bold">
{{ urlMetadata[modelKey]?.filename }}
</span>
<span
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
</span>
</div>
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
/>
<span class="text-xs/tight text-warning-background">
{{
t('rightSidePanel.missingModels.typeMismatch', {
detectedType: typeMismatch
})
}}
</span>
</div>
<div class="pt-0.5">
<Button
variant="primary"
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
:loading="urlImporting[modelKey]"
@click="handleImportClick"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
{{
typeMismatch
? t('rightSidePanel.missingModels.importAnyway')
: t('rightSidePanel.missingModels.import')
}}
</Button>
</div>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div
v-if="urlFetching[modelKey]"
aria-live="polite"
class="flex items-center justify-center py-2"
>
<i
aria-hidden="true"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="sr-only">{{ t('g.loading') }}</span>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
<span class="text-xs text-destructive-background-hover">
{{ urlErrors[modelKey] }}
</span>
</div>
</TransitionCollapse>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { formatSize } from '@/utils/formatUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
const { modelKey, directory, typeMismatch } = defineProps<{
modelKey: string
directory: string | null
typeMismatch: string | null
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const canImportModels = computed(() => flags.privateModelsEnabled)
const { showUploadDialog } = useModelUpload()
const store = useMissingModelStore()
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
storeToRefs(store)
const { handleUrlInput, handleImport } = useMissingModelInteractions()
function handleImportClick() {
if (canImportModels.value) {
handleImport(modelKey, directory)
} else {
showUploadDialog()
}
}
</script>

View File

@@ -1,38 +1,26 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
const mockResolveNodeDisplayName = vi.fn()
const mockValidateSourceUrl = vi.fn()
const mockGetAssetMetadata = vi.fn()
const mockUploadAssetAsync = vi.fn()
const mockTrackDownload = vi.fn()
const mockInvalidateModelsForCategory = vi.fn()
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
@@ -55,7 +43,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
updateModelsForTag: vi.fn()
@@ -77,42 +64,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args),
uploadAssetAsync: (...args: unknown[]) => mockUploadAssetAsync(...args)
}
}))
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com', 'civitai.red']
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
type: 'huggingface',
name: 'Hugging Face',
hostnames: ['huggingface.co']
}
}))
vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
}))
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
@@ -133,17 +87,54 @@ function makeCandidate(
}
describe('useMissingModelInteractions', () => {
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupMissingModelInteractions(): ReturnType<
typeof useMissingModelInteractions
> {
return setupWithI18n(() => useMissingModelInteractions())
}
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
@@ -184,149 +175,28 @@ describe('useMissingModelInteractions', () => {
})
})
describe('getComboValue', () => {
it('returns undefined when node is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns undefined when widget is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'other_widget', value: 'test' }]
})
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns string value directly', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
})
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
})
it('returns stringified number value', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 42 }]
})
expect(getComboValue(makeCandidate())).toBe('42')
})
it('returns undefined for unexpected types', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
})
expect(getComboValue(makeCandidate())).toBeUndefined()
})
it('returns undefined when nodeId is null', () => {
const result = getComboValue(makeCandidate({ nodeId: undefined }))
expect(result).toBeUndefined()
})
})
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
const { isModelExpanded } = useMissingModelInteractions()
const { isModelExpanded } = setupMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
setupMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
setupMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
describe('handleComboSelect', () => {
it('sets selectedLibraryModel in store', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', 'model_v2.safetensors')
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
})
it('does not set value when undefined', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', undefined)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
})
describe('isSelectionConfirmable', () => {
it('returns false when no selection exists', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when download is running', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importTaskIds['key1'] = 'task-123'
mockDownloadList.mockReturnValue([
{ taskId: 'task-123', status: 'running' }
])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when importCategoryMismatch exists', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns true when selection is ready with no active download', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockDownloadList.mockReturnValue([])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
})
describe('cancelLibrarySelect', () => {
it('clears selectedLibraryModel and importCategoryMismatch', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { cancelLibrarySelect } = useMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
})
})
describe('confirmLibrarySelect', () => {
it('updates widget values on referencing nodes and removes missing model', () => {
const mockGraph = {}
@@ -347,6 +217,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.importTaskIds['key1'] = 'task-123'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
@@ -354,7 +225,7 @@ describe('useMissingModelInteractions', () => {
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
@@ -372,6 +243,7 @@ describe('useMissingModelInteractions', () => {
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importTaskIds['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
@@ -379,7 +251,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -391,7 +263,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -407,169 +279,16 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
})
})
describe('handleUrlInput', () => {
it('clears previous state on new input', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'old' } as never
store.urlErrors['key1'] = 'old error'
store.urlFetching['key1'] = true
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
expect(store.urlMetadata['key1']).toBeUndefined()
expect(store.urlErrors['key1']).toBeUndefined()
expect(store.urlFetching['key1']).toBe(false)
})
it('does not set debounce timer for empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', ' ')
expect(setTimerSpy).not.toHaveBeenCalled()
})
it('sets debounce timer for non-empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(setTimerSpy).toHaveBeenCalledWith(
'key1',
expect.any(Function),
800
)
})
it('clears previous debounce timer', () => {
const store = useMissingModelStore()
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
})
})
describe('getTypeMismatch', () => {
it('returns null when groupDirectory is null', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', null)).toBeNull()
})
it('returns null when no metadata exists', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when metadata has no tags', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when detected type matches directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['checkpoints']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns detected type when it differs from directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['loras']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
})
it('returns null when tags contain no recognized model type', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['other', 'random']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
describe('getComboOptions', () => {
it('returns assets from assetsStore when the model is asset-supported', () => {
mockGetAssets.mockReturnValueOnce([
{ name: 'modelA.safetensors' },
{ name: 'modelB.safetensors' }
])
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate({ isAssetSupported: true }))
expect(mockGetAssets).toHaveBeenCalledWith('CheckpointLoaderSimple')
expect(options).toEqual([
{ name: 'modelA.safetensors', value: 'modelA.safetensors' },
{ name: 'modelB.safetensors', value: 'modelB.safetensors' }
])
})
it('returns widget options when the model is not asset-supported', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [
{
name: 'ckpt_name',
value: '',
options: { values: ['v1.safetensors', 'v2.safetensors'] }
}
]
})
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate())
expect(options).toEqual([
{ name: 'v1.safetensors', value: 'v1.safetensors' },
{ name: 'v2.safetensors', value: 'v2.safetensors' }
])
})
it('returns an empty array when the widget has no options.values', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: '' }]
})
const { getComboOptions } = useMissingModelInteractions()
expect(getComboOptions(makeCandidate())).toEqual([])
})
})
describe('getDownloadStatus', () => {
it('returns null when no taskId is tracked for the key', () => {
const { getDownloadStatus } = useMissingModelInteractions()
const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
})
@@ -581,7 +300,7 @@ describe('useMissingModelInteractions', () => {
{ taskId: 'task-42', status: 'created' }
])
const { getDownloadStatus } = useMissingModelInteractions()
const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
taskId: 'task-42',
status: 'created'
@@ -589,29 +308,20 @@ describe('useMissingModelInteractions', () => {
})
})
describe('handleImport', () => {
const setupImportableState = (key: string) => {
describe('handleUploadedModelImport', () => {
it('tracks an async-pending result via importTaskIds and trackDownload', () => {
const store = useMissingModelStore()
store.urlInputs[key] = 'https://civitai.com/models/123'
store.urlMetadata[key] = {
filename: 'model.safetensors',
name: 'model'
} as never
mockValidateSourceUrl.mockReturnValue(true)
return store
}
it('tracks an async-pending result via importTaskIds and trackDownload', async () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'async',
task: { task_id: 'task-99', status: 'created' }
const { handleUploadedModelImport } = setupMissingModelInteractions()
handleUploadedModelImport('key1', {
status: 'processing',
taskId: 'task-99',
modelType: 'checkpoints',
filename: 'model.safetensors'
})
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importTaskIds['key1']).toBe('task-99')
expect(store.selectedLibraryModel['key1']).toBe('model.safetensors')
expect(mockTrackDownload).toHaveBeenCalledWith(
'task-99',
'checkpoints',
@@ -619,43 +329,17 @@ describe('useMissingModelInteractions', () => {
)
})
it('invalidates model caches when the async result is already completed', async () => {
setupImportableState('key1')
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'async',
task: { task_id: 'task-100', status: 'completed' }
it('invalidates model caches when the result is already completed', () => {
const { handleUploadedModelImport } = setupMissingModelInteractions()
handleUploadedModelImport('key1', {
status: 'success',
modelType: 'checkpoints',
filename: 'model.safetensors'
})
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
it('records importCategoryMismatch when sync result tags differ from groupDirectory', async () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'sync',
asset: { tags: ['models', 'loras'] }
})
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importCategoryMismatch['key1']).toBe('loras')
})
it('writes the error message to urlErrors when the upload rejects', async () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockRejectedValueOnce(new Error('Upload boom'))
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.urlErrors['key1']).toBe('Upload boom')
expect(store.urlImporting['key1']).toBe(false)
})
})
})

View File

@@ -1,39 +1,13 @@
import { useI18n } from 'vue-i18n'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import type { UploadModelSuccess } from '@/platform/assets/composables/useUploadModelWizard'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
MissingModelCandidate,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const importSources = [civitaiImportSource, huggingfaceImportSource]
const MODEL_TYPE_TAGS = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const URL_DEBOUNCE_MS = 800
import type { MissingModelViewModel } from '@/platform/missingModel/types'
export function getModelStateKey(
modelName: string,
@@ -58,42 +32,12 @@ export function getNodeDisplayLabel(
})
}
function getModelComboWidget(
model: MissingModelCandidate
): { node: LGraphNode; widget: IBaseWidget } | null {
if (model.nodeId == null) return null
const graph = app.rootGraph
if (!graph) return null
const node = getNodeByExecutionId(graph, String(model.nodeId))
if (!node) return null
const widget = node.widgets?.find((w) => w.name === model.widgetName)
if (!widget) return null
return { node, widget }
}
export function getComboValue(
model: MissingModelCandidate
): string | undefined {
const result = getModelComboWidget(model)
if (!result) return undefined
const val = result.widget.value
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
return undefined
}
export function useMissingModelInteractions() {
const { t } = useI18n()
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const _requestTokens: Record<string, symbol> = {}
function toggleModelExpand(key: string) {
store.modelExpandState[key] = !isModelExpanded(key)
}
@@ -102,49 +46,6 @@ export function useMissingModelInteractions() {
return store.modelExpandState[key] ?? false
}
function getComboOptions(
model: MissingModelCandidate
): { name: string; value: string }[] {
if (model.isAssetSupported && model.nodeType) {
const assets = assetsStore.getAssets(model.nodeType) ?? []
return assets.map((asset) => ({
name: getAssetDisplayName(asset),
value: getAssetFilename(asset)
}))
}
const result = getModelComboWidget(model)
if (!result) return []
const values = result.widget.options?.values
if (!Array.isArray(values)) return []
return values.map((v) => ({ name: String(v), value: String(v) }))
}
function handleComboSelect(key: string, value: string | undefined) {
if (value) {
store.selectedLibraryModel[key] = value
}
}
function isSelectionConfirmable(key: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
const status = getDownloadStatus(key)
if (
status &&
(status.status === 'running' || status.status === 'created')
) {
return false
}
return true
}
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
function confirmLibrarySelect(
key: string,
@@ -189,97 +90,11 @@ export function useMissingModelInteractions() {
}
delete store.selectedLibraryModel[key]
delete store.importTaskIds[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
function handleUrlInput(key: string, value: string) {
store.urlInputs[key] = value
delete store.urlMetadata[key]
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
store.urlFetching[key] = false
store.clearDebounceTimer(key)
const trimmed = value.trim()
if (!trimmed) return
store.setDebounceTimer(
key,
() => {
void fetchUrlMetadata(key, trimmed)
},
URL_DEBOUNCE_MS
)
}
async function fetchUrlMetadata(key: string, url: string) {
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) {
store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
return
}
const token = Symbol()
_requestTokens[key] = token
store.urlFetching[key] = true
delete store.urlErrors[key]
try {
const metadata = await assetService.getAssetMetadata(url)
if (_requestTokens[key] !== token) return
if (metadata.filename) {
try {
const decoded = decodeURIComponent(metadata.filename)
const basename = decoded.split(/[/\\]/).pop() ?? decoded
if (!basename.includes('..')) {
metadata.filename = basename
}
} catch {
/* keep original */
}
}
store.urlMetadata[key] = metadata
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.metadataFetchFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlFetching[key] = false
}
}
}
function getTypeMismatch(
key: string,
groupDirectory: string | null
): string | null {
if (!groupDirectory) return null
const metadata = store.urlMetadata[key]
if (!metadata?.tags?.length) return null
const detectedType = metadata.tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (!detectedType) return null
if (detectedType !== groupDirectory) {
return detectedType
}
return null
}
function getDownloadStatus(key: string) {
const taskId = store.importTaskIds[key]
if (!taskId) return null
@@ -307,87 +122,21 @@ export function useMissingModelInteractions() {
}
}
function handleSyncResult(
key: string,
tags: string[],
modelType: string | undefined
) {
const existingCategory = tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (existingCategory && modelType && existingCategory !== modelType) {
store.importCategoryMismatch[key] = existingCategory
function handleUploadedModelImport(key: string, result: UploadModelSuccess) {
if (result.taskId) {
handleAsyncPending(key, result.taskId, result.modelType, result.filename)
} else if (result.status === 'success') {
handleAsyncCompleted(result.modelType)
}
}
async function handleImport(key: string, groupDirectory: string | null) {
const metadata = store.urlMetadata[key]
if (!metadata) return
const url = store.urlInputs[key]?.trim()
if (!url) return
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) return
const token = Symbol()
_requestTokens[key] = token
store.urlImporting[key] = true
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
try {
const modelType = groupDirectory || undefined
const tags = modelType ? ['models', modelType] : ['models']
const filename = metadata.filename || metadata.name || 'model'
const result = await assetService.uploadAssetAsync({
source_url: url,
tags,
user_metadata: {
source: source.type,
source_url: url,
model_type: modelType
}
})
if (_requestTokens[key] !== token) return
if (result.type === 'async' && result.task.status !== 'completed') {
handleAsyncPending(key, result.task.task_id, modelType, filename)
} else if (result.type === 'async') {
handleAsyncCompleted(modelType)
} else if (result.type === 'sync') {
handleSyncResult(key, result.asset.tags ?? [], modelType)
}
store.selectedLibraryModel[key] = filename
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.importFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlImporting[key] = false
}
}
store.selectedLibraryModel[key] = result.filename
}
return {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
handleUrlInput,
getTypeMismatch,
getDownloadStatus,
handleImport
handleUploadedModelImport
}
}

View File

@@ -214,7 +214,7 @@ describe('missingModelStore', () => {
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.urlInputs['test-key'] = 'https://example.com'
store.modelExpandState['test-key'] = true
store.selectedLibraryModel['test-key'] = 'some-model'
expect(store.missingModelCandidates).not.toBeNull()
@@ -222,7 +222,7 @@ describe('missingModelStore', () => {
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
expect(store.urlInputs).toEqual({})
expect(store.modelExpandState).toEqual({})
expect(store.selectedLibraryModel).toEqual({})
})
})
@@ -515,17 +515,19 @@ describe('missingModelStore', () => {
makeModelCandidate('shared.safetensors', { nodeId: '65:80:5' }),
makeModelCandidate('only-interior.safetensors', { nodeId: '65:70:64' })
])
store.urlInputs['shared.safetensors'] = 'https://example.com/shared'
store.urlInputs['only-interior.safetensors'] =
'https://example.com/interior'
store.selectedLibraryModel['shared.safetensors'] = 'shared-replacement'
store.selectedLibraryModel['only-interior.safetensors'] =
'interior-replacement'
store.removeMissingModelsByPrefix('65:70:')
// 'only-interior' fully removed → interaction state cleared.
// 'shared' still referenced by 65:80:5 → interaction state preserved.
expect(store.urlInputs['only-interior.safetensors']).toBeUndefined()
expect(store.urlInputs['shared.safetensors']).toBe(
'https://example.com/shared'
expect(
store.selectedLibraryModel['only-interior.safetensors']
).toBeUndefined()
expect(store.selectedLibraryModel['shared.safetensors']).toBe(
'shared-replacement'
)
})
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
import { computed, ref } from 'vue'
import { t } from '@/i18n'
// eslint-disable-next-line import-x/no-restricted-paths
@@ -7,7 +7,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
@@ -77,26 +76,16 @@ export const useMissingModelStore = defineStore('missingModel', () => {
)
})
// Persists across component re-mounts so that download progress,
// URL inputs, etc. survive tab switches within the right-side panel.
// Persists across component re-mounts so that download progress
// survives tab switches within the right-side panel.
const modelExpandState = ref<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const importTaskIds = ref<Record<string, string>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
const urlErrors = ref<Record<string, string>>({})
const urlImporting = ref<Record<string, boolean>>({})
const folderPaths = ref<Record<string, string[]>>({})
const fileSizes = ref<Record<string, number>>({})
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
let _verificationAbortController: AbortController | null = null
onScopeDispose(cancelDebounceTimers)
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
@@ -134,13 +123,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
function clearInteractionStateForName(name: string) {
delete modelExpandState.value[name]
delete selectedLibraryModel.value[name]
delete importCategoryMismatch.value[name]
delete importTaskIds.value[name]
delete urlInputs.value[name]
delete urlMetadata.value[name]
delete urlFetching.value[name]
delete urlErrors.value[name]
delete urlImporting.value[name]
}
function removeMissingModelsByNodeId(nodeId: string) {
@@ -222,31 +205,6 @@ export const useMissingModelStore = defineStore('missingModel', () => {
return activeMissingModelGraphIds.value.has(String(node.id))
}
function cancelDebounceTimers() {
for (const key of Object.keys(_urlDebounceTimers)) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setDebounceTimer(
key: string,
callback: () => void,
delayMs: number
) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
}
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
}
function clearDebounceTimer(key: string) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setFolderPaths(paths: Record<string, string[]>) {
folderPaths.value = paths
}
@@ -259,16 +217,9 @@ export const useMissingModelStore = defineStore('missingModel', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingModelCandidates.value = null
cancelDebounceTimers()
modelExpandState.value = {}
selectedLibraryModel.value = {}
importCategoryMismatch.value = {}
importTaskIds.value = {}
urlInputs.value = {}
urlMetadata.value = {}
urlFetching.value = {}
urlErrors.value = {}
urlImporting.value = {}
folderPaths.value = {}
fileSizes.value = {}
}
@@ -323,19 +274,10 @@ export const useMissingModelStore = defineStore('missingModel', () => {
modelExpandState,
selectedLibraryModel,
importTaskIds,
importCategoryMismatch,
urlInputs,
urlMetadata,
urlFetching,
urlErrors,
urlImporting,
folderPaths,
fileSizes,
setFolderPaths,
setFileSize,
setDebounceTimer,
clearDebounceTimer
setFileSize
}
})

View File

@@ -49,6 +49,7 @@ onUnmounted(() => {
:can-export="viewer.canExport"
:material-modes="viewer.materialModes"
:has-skeleton="viewer.hasSkeleton"
:source-format="viewer.sourceFormat"
@update-background-image="viewer.handleBackgroundImageUpdate"
@export-model="viewer.exportModel"
/>

View File

@@ -1170,7 +1170,7 @@ export class ComfyApp {
useWorkflowService().beforeLoadNewGraph()
if (skipAssetScans) {
// Only reset candidates; preserve UI state (fileSizes, urlInputs, etc.)
// Only reset candidates; preserve UI state (fileSizes, etc.)
// so cached results restored by showPendingWarnings still display sizes.
// Abort any in-flight verification from the outgoing workflow so a late
// result cannot repopulate the store after we've switched workflows.

View File

@@ -37,6 +37,7 @@ type UseLoad3dViewerFn = (node?: LGraphNode) => {
isStandaloneMode: { value: boolean }
isSplatModel: { value: boolean }
isPlyModel: { value: boolean }
sourceFormat: { value: string | null }
animations: { value: AnimationItem[] }
playing: { value: boolean }
selectedSpeed: { value: number }