mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-13 01:38:10 +00:00
Compare commits
10 Commits
alexis/upd
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e383d9fe60 | ||
|
|
7365382a0a | ||
|
|
c25657773f | ||
|
|
65ef4969b7 | ||
|
|
97a47a1a91 | ||
|
|
796b4496f8 | ||
|
|
c1a45eae35 | ||
|
|
b2129921cc | ||
|
|
0a9daab8a4 | ||
|
|
b19e9999aa |
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { expect } 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 +39,54 @@ 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]
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Errors tab - Cloud missing models',
|
||||
{ tag: ['@cloud', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlags({
|
||||
model_upload_button_enabled: true,
|
||||
private_models_enabled: true
|
||||
})
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
@@ -88,5 +141,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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -3089,6 +3089,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",
|
||||
@@ -3641,32 +3648,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.",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
src/platform/assets/components/UploadModelFooter.test.ts
Normal file
60
src/platform/assets/components/UploadModelFooter.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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<{
|
||||
|
||||
74
src/platform/assets/components/UploadModelProgress.test.ts
Normal file
74
src/platform/assets/components/UploadModelProgress.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
460
src/platform/missingModel/components/MissingModelRow.test.ts
Normal file
460
src/platform/missingModel/components/MissingModelRow.test.ts
Normal 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'
|
||||
},
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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, '')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user