mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-15 19:16:10 +00:00
Compare commits
1 Commits
v1.47.0
...
cloud/1.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4c3b833b |
@@ -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,29 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
Asset,
|
||||
AssetCreated,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
|
||||
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
|
||||
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
|
||||
'models/checkpoints/cloud_importable_model.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
@@ -27,13 +40,62 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
}
|
||||
}
|
||||
|
||||
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-existing-cloud-importable-model',
|
||||
name: 'asset-record-display-name.safetensors',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
|
||||
size: 2_048,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
|
||||
function getRequestedIncludeTags(requestUrl: string): string[] {
|
||||
return (
|
||||
new URL(requestUrl).searchParams
|
||||
.get('include_tags')
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function filterAssetsByRequest(
|
||||
assets: ReadonlyArray<Asset>,
|
||||
requestUrl: string
|
||||
): Asset[] {
|
||||
const includeTags = getRequestedIncludeTags(requestUrl)
|
||||
return includeTags.length
|
||||
? assets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: [...assets]
|
||||
}
|
||||
|
||||
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
model_upload_button_enabled: true,
|
||||
private_models_enabled: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Errors tab - Cloud missing models',
|
||||
{ tag: ['@cloud', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableMissingModelImportFeatures(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
@@ -88,5 +150,216 @@ test.describe(
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
|
||||
test('separates importable cloud models from unsupported rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
const importableRows = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelImportableRows
|
||||
)
|
||||
const unsupportedSection = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelUnsupportedSection
|
||||
)
|
||||
|
||||
await expect(
|
||||
importableRows.getByRole('button', {
|
||||
name: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(unsupportedSection).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Import Not Supported')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(
|
||||
/Nodes that reference the models below do not support imported models/
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Unknown', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByRole('button', {
|
||||
name: 'Load Image',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('opens cloud import with missing-model replacement context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 1024,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: 'replacement.safetensors',
|
||||
tags: ['loras']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const urlInput = comfyPage.page.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await expect(urlInput).toBeVisible()
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
|
||||
)
|
||||
await comfyPage.page
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
|
||||
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
/Locked to (Checkpoints|checkpoints) for this missing model/
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('uses the synced asset filename when applying an already imported cloud model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let isImportedAssetAvailable = false
|
||||
const visibleAssets = () =>
|
||||
isImportedAssetAvailable
|
||||
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
|
||||
: [LOTUS_DIFFUSION_MODEL]
|
||||
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
|
||||
const assets = filterAssetsByRequest(
|
||||
visibleAssets(),
|
||||
route.request().url()
|
||||
)
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 2048,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
tags: ['checkpoints']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/download', (route) => {
|
||||
isImportedAssetAvailable = true
|
||||
const response: AssetCreated = {
|
||||
...EXISTING_CLOUD_IMPORTABLE_MODEL,
|
||||
created_new: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
const urlInput = uploadDialog.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
|
||||
)
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step2-confirm-button"]')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
|
||||
?.value
|
||||
})
|
||||
)
|
||||
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.0",
|
||||
"version": "1.46.14",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "الصوت",
|
||||
"tooltip": "الصوت الذي سيتم إضافته للفيديو."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "عمق البت للفيديو المُنشأ. عمق ١٠ بت يحافظ على تدرجات أكثر سلاسة مع تقليل التدرج اللوني، لكن بعض المشغلات والعُقد اللاحقة قد لا تدعمه."
|
||||
},
|
||||
"fps": {
|
||||
"name": "الإطارات في الثانية"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "معدل_الإطارات",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "استيراد نموذج ثلاثي الأبعاد خارجي (مثلاً من Rodin، Hunyuan3D أو ملف محلي) إلى Tripo لاستخدامه مع عُقد المعالجة اللاحقة في Tripo: الإكساء، التحريك، التحويل. يُوصى باستخدام GLB: تبقى الخامات محفوظة فقط إذا كانت مضمنة داخل الملف. يرجى ملاحظة أن إكساء نموذج مستورد يتطلب مطالبة إكساء.",
|
||||
"display_name": "Tripo: استيراد نموذج",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "نموذج ثلاثي الأبعاد للاستيراد (GLB / FBX / OBJ / STL، حتى ١٥٠ ميجابايت). ملفات OBJ و STL لا تحتوي على خامات مضمنة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: متعدد المناظر إلى نموذج",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "محاذاة_الملمس"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "إرشادات نصية اختيارية للإكساء. مطلوبة عملياً للنماذج المستوردة (Tripo: استيراد نموذج)، والتي لا تحتوي على صورة مصدر لاستنتاج الألوان منها."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "جودة_الملمس"
|
||||
},
|
||||
|
||||
@@ -3091,6 +3091,13 @@
|
||||
"loadingModels": "Loading {type}...",
|
||||
"maxFileSize": "Max file size: {size}",
|
||||
"maxFileSizeValue": "1 GB",
|
||||
"missingModelImportTypeLocked": "Locked to {type} for this missing model",
|
||||
"missingModelImportTypeMismatchAlreadyImported": "This file is already imported as {actual}.",
|
||||
"missingModelImportTypeMismatchNextAction": "Try importing a different {required} model that this node can use.",
|
||||
"missingModelImportTypeMismatchRequired": "This node requires {required}, so this import cannot resolve the missing model.",
|
||||
"missingModelImportTypeMismatchTitle": "This model cannot resolve the missing model.",
|
||||
"missingModelImportUnknownType": "another model type",
|
||||
"missingModelImportWillReplace": "This import will replace {model} in:",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
@@ -3643,32 +3650,17 @@
|
||||
"expand": "Expand"
|
||||
},
|
||||
"missingModels": {
|
||||
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
|
||||
"or": "OR",
|
||||
"useFromLibrary": "Use from Library",
|
||||
"usingFromLibrary": "Using from Library",
|
||||
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
|
||||
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"imported": "Imported",
|
||||
"importFailed": "Import failed",
|
||||
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
|
||||
"importAnyway": "Import Anyway",
|
||||
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
|
||||
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
|
||||
"customNodeDownloadDisabled": "Nodes that reference the models below do not support imported models. Open the node to choose a supported built-in model, or replace it with a standard node that supports imported models.",
|
||||
"importNotSupported": "Import Not Supported",
|
||||
"copyModelName": "Copy model name",
|
||||
"copyUrl": "Copy URL",
|
||||
"confirmSelection": "Confirm selection",
|
||||
"locateNode": "Locate node on canvas",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"clearUrl": "Clear URL",
|
||||
"expandNodes": "Show referencing nodes",
|
||||
"collapseNodes": "Hide referencing nodes",
|
||||
"unknownCategory": "Unknown",
|
||||
"missingModelsTitle": "Missing Models",
|
||||
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
|
||||
"downloadAll": "Download all",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing missing models.",
|
||||
|
||||
@@ -2910,10 +2910,6 @@
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "The audio to add to the video."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Bit depth of the created video. 10-bit keeps smoother gradients with less banding, but some players and downstream nodes may not support it."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -5097,7 +5093,7 @@
|
||||
},
|
||||
"GetVideoComponents": {
|
||||
"display_name": "Get Video Components",
|
||||
"description": "Extracts all components from a video: frames, audio, framerate, and bit depth.",
|
||||
"description": "Extracts all components from a video: frames, audio, and framerate.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video",
|
||||
@@ -5116,10 +5112,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"display_name": "Tripo: Import Model",
|
||||
"description": "Import an external 3D model (e.g. from Rodin, Hunyuan3D or a local file) into Tripo to use it with Tripo's post-processing nodes: Texture, Rig, Convert. GLB is recommended: textures survive import only when embedded in the file. Note that texturing an imported model requires a texture prompt.",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "3D model to import (GLB / FBX / OBJ / STL, up to 150 MB). OBJ and STL files carry no embedded textures."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Multiview to Model",
|
||||
"inputs": {
|
||||
@@ -20083,10 +20059,6 @@
|
||||
},
|
||||
"texture_alignment": {
|
||||
"name": "texture_alignment"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Optional text guidance for texturing. Required in practice for imported models (Tripo: Import Model), which carry no source image to infer colors from."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "audio",
|
||||
"tooltip": "El audio que se añadirá al video."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Profundidad de bits del video creado. 10 bits mantiene gradientes más suaves con menos bandas, pero algunos reproductores y nodos posteriores pueden no soportarlo."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Importa un modelo 3D externo (por ejemplo, de Rodin, Hunyuan3D o un archivo local) en Tripo para usarlo con los nodos de postprocesamiento de Tripo: Texture, Rig, Convert. Se recomienda GLB: las texturas solo se conservan si están incrustadas en el archivo. Ten en cuenta que para texturizar un modelo importado se requiere un prompt de textura.",
|
||||
"display_name": "Tripo: Importar modelo",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Modelo 3D a importar (GLB / FBX / OBJ / STL, hasta 150 MB). Los archivos OBJ y STL no contienen texturas incrustadas."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Multivista a Modelo",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "alineación_de_textura"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Guía de texto opcional para texturizar. Es necesaria en la práctica para modelos importados (Tripo: Importar modelo), que no tienen imagen de origen para inferir colores."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "calidad_de_textura"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "صدا",
|
||||
"tooltip": "صدایی که به ویدیو اضافه میشود."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "عمق بیت ویدئوی ایجادشده. ۱۰-بیت گرادیانهای نرمتری با بندینگ کمتر ایجاد میکند، اما ممکن است برخی پخشکنندهها و nodeهای بعدی از آن پشتیبانی نکنند."
|
||||
},
|
||||
"fps": {
|
||||
"name": "فریم بر ثانیه"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "نرخ فریم",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "یک مدل سهبعدی خارجی (مثلاً از Rodin، Hunyuan3D یا یک فایل محلی) را به Tripo وارد کنید تا بتوانید از آن با nodeهای پسپردازش Tripo مانند Texture، Rig و Convert استفاده کنید. فرمت GLB توصیه میشود: با این فرمت، تکسچرها فقط زمانی پس از وارد کردن باقی میمانند که در فایل جاسازی شده باشند. توجه داشته باشید که تکسچر دادن به یک مدل واردشده نیازمند یک texture prompt است.",
|
||||
"display_name": "Tripo: وارد کردن مدل",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "مدل سهبعدی برای وارد کردن (GLB / FBX / OBJ / STL، تا سقف ۱۵۰ مگابایت). فایلهای OBJ و STL فاقد تکسچر جاسازیشده هستند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: چندنما به مدل",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "تراز بافت"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "راهنمای متنی اختیاری برای تکسچر دادن. در عمل برای مدلهای واردشده (Tripo: وارد کردن مدل) که تصویر مرجعی برای استخراج رنگ ندارند، الزامی است."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "کیفیت بافت"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "audio",
|
||||
"tooltip": "L’audio à ajouter à la vidéo."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Profondeur de bits de la vidéo créée. Le 10 bits conserve des dégradés plus doux avec moins de bandes, mais certains lecteurs et nœuds en aval peuvent ne pas le prendre en charge."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "ips",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Importez un modèle 3D externe (par exemple depuis Rodin, Hunyuan3D ou un fichier local) dans Tripo pour l'utiliser avec les nœuds de post-traitement de Tripo : Texture, Rig, Convert. GLB est recommandé : les textures ne sont conservées à l'import que si elles sont intégrées dans le fichier. Notez que le texturage d'un modèle importé nécessite une invite de texture.",
|
||||
"display_name": "Tripo : Importer un modèle",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Modèle 3D à importer (GLB / FBX / OBJ / STL, jusqu'à 150 Mo). Les fichiers OBJ et STL ne contiennent pas de textures intégrées."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo : Multivue vers Modèle",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "alignement_texture"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Guidage textuel optionnel pour le texturage. Requis en pratique pour les modèles importés (Tripo : Importer un modèle), qui ne contiennent pas d'image source pour déduire les couleurs."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "qualité_texture"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "オーディオ",
|
||||
"tooltip": "動画に追加するオーディオです。"
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "作成される動画のビット深度。10ビットはグラデーションがより滑らかでバンディングが少なくなりますが、一部のプレーヤーや下流ノードではサポートされない場合があります。"
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "外部の3Dモデル(例:Rodin、Hunyuan3D、またはローカルファイル)をTripoにインポートし、Tripoのポストプロセスノード(Texture、Rig、Convert)で使用します。GLB形式を推奨します:テクスチャはファイルに埋め込まれている場合のみインポート時に保持されます。インポートしたモデルにテクスチャを付与するには、テクスチャプロンプトが必要です。",
|
||||
"display_name": "Tripo: Import Model",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "インポートする3Dモデル(GLB / FBX / OBJ / STL、最大150MB)。OBJおよびSTLファイルには埋め込みテクスチャがありません。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: マルチビューからモデル",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "texture_alignment"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "テクスチャ付与のためのオプションのテキストガイダンス。インポートしたモデル(Tripo: Import Model)には参照画像がないため、実際には必須です。"
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "texture_quality"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "오디오",
|
||||
"tooltip": "비디오에 추가할 오디오입니다."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "생성된 비디오의 비트 깊이입니다. 10비트는 더 부드러운 그라데이션과 적은 밴딩을 제공하지만, 일부 플레이어나 후속 노드에서 지원되지 않을 수 있습니다."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "외부 3D 모델(예: Rodin, Hunyuan3D 또는 로컬 파일)을 Tripo로 가져와 Tripo의 후처리 노드(Texture, Rig, Convert)와 함께 사용할 수 있습니다. GLB 형식을 권장합니다: 텍스처가 파일에 임베드되어 있을 때만 가져오기가 가능합니다. 가져온 모델에 텍스처를 적용하려면 텍스처 프롬프트가 필요합니다.",
|
||||
"display_name": "Tripo: Import Model",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "가져올 3D 모델(GLB / FBX / OBJ / STL, 최대 150MB). OBJ와 STL 파일은 임베드된 텍스처를 포함하지 않습니다."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: 다중 뷰에서 모델 생성",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "텍스처 정렬"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "텍스처링을 위한 선택적 텍스트 가이드입니다. 가져온 모델(Tripo: Import Model)의 경우 실제로 필요하며, 색상을 추론할 소스 이미지가 없기 때문입니다."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "텍스처 품질"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "áudio",
|
||||
"tooltip": "O áudio a ser adicionado ao vídeo."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Profundidade de bits do vídeo criado. 10 bits mantém gradientes mais suaves com menos faixas, mas alguns players e nós subsequentes podem não suportar."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Importe um modelo 3D externo (por exemplo, do Rodin, Hunyuan3D ou de um arquivo local) para o Tripo para usá-lo com os nós de pós-processamento do Tripo: Texture, Rig, Convert. GLB é recomendado: as texturas só permanecem após a importação quando estão incorporadas no arquivo. Observe que texturizar um modelo importado requer um prompt de textura.",
|
||||
"display_name": "Tripo: Importar Modelo",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Modelo 3D para importar (GLB / FBX / OBJ / STL, até 150 MB). Arquivos OBJ e STL não possuem texturas incorporadas."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Multiview para Modelo",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "alinhamento_textura"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Orientação textual opcional para texturização. Necessário na prática para modelos importados (Tripo: Importar Modelo), que não possuem imagem de origem para inferir cores."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "qualidade_textura"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "аудио",
|
||||
"tooltip": "Аудио, которое будет добавлено к видео."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Глубина цвета создаваемого видео. 10-бит обеспечивает более плавные градиенты с меньшим количеством полос, но некоторые проигрыватели и последующие узлы могут не поддерживать этот формат."
|
||||
},
|
||||
"fps": {
|
||||
"name": "кадров в секунду"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Импорт внешней 3D-модели (например, из Rodin, Hunyuan3D или локального файла) в Tripo для использования с постобработкой Tripo: Текстурирование, Скелетирование, Конвертация. Рекомендуется GLB: текстуры сохраняются только при встраивании в файл. Обратите внимание, что для текстурирования импортированной модели требуется текстурный запрос.",
|
||||
"display_name": "Tripo: Импорт модели",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "3D-модель для импорта (GLB / FBX / OBJ / STL, до 150 МБ). Файлы OBJ и STL не содержат встроенных текстур."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Мультивью в модель",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "texture_alignment"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Необязательное текстовое описание для текстурирования. На практике требуется для импортированных моделей (Tripo: Импорт модели), которые не содержат исходного изображения для определения цветов."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "texture_quality"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "ses",
|
||||
"tooltip": "Videoya eklenecek ses."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Oluşturulan videonun bit derinliği. 10-bit, daha az bantlanma ile daha yumuşak geçişler sağlar, ancak bazı oynatıcılar ve sonraki düğümler bunu desteklemeyebilir."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Harici bir 3D modeli (ör. Rodin, Hunyuan3D veya yerel bir dosyadan) Tripo'ya aktararak Tripo'nun son işlem düğümleriyle kullanın: Texture, Rig, Convert. GLB önerilir: dokular yalnızca dosyaya gömülü olduğunda aktarımda korunur. Aktarılan bir modelin dokulandırılması için bir doku istemi gerektiğini unutmayın.",
|
||||
"display_name": "Tripo: Model İçe Aktar",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "İçe aktarılacak 3D model (GLB / FBX / OBJ / STL, en fazla 150 MB). OBJ ve STL dosyalarında gömülü doku bulunmaz."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Çok Bakışlıdan Modele",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "doku_hizalama"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Dokulandırma için isteğe bağlı metin rehberi. Pratikte, renkleri çıkarmak için kaynak görseli olmayan aktarılan modeller (Tripo: Model İçe Aktar) için gereklidir."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "doku_kalitesi"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "音訊",
|
||||
"tooltip": "要加入影片的音訊。"
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "所建立影片的位元深度。10 位元可保持更平滑的漸層並減少色帶現象,但部分播放器與後續節點可能不支援。"
|
||||
},
|
||||
"fps": {
|
||||
"name": "每秒影格數"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "每秒影格數",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "將外部 3D 模型(例如來自 Rodin、Hunyuan3D 或本機檔案)匯入 Tripo,以搭配 Tripo 的後處理節點使用:材質、骨架、轉換。建議使用 GLB 格式:僅當材質嵌入檔案時,匯入後才會保留材質。請注意,對匯入模型進行材質處理時需提供材質提示詞。",
|
||||
"display_name": "Tripo:匯入模型",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "要匯入的 3D 模型(GLB / FBX / OBJ / STL,最大 150 MB)。OBJ 與 STL 檔案不包含嵌入材質。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo:多視角轉模型",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "紋理對齊"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "材質處理的選用文字指引。實際上,對於匯入的模型(Tripo:匯入模型)必須提供,因為這些模型沒有來源圖像可推斷顏色。"
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "紋理品質"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "音频",
|
||||
"tooltip": "要添加到视频中的音频。"
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "所创建视频的位深度。10位可以保持更平滑的渐变并减少色带,但部分播放器和下游节点可能不支持。"
|
||||
},
|
||||
"fps": {
|
||||
"name": "帧率"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "帧率",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "将外部3D模型(例如来自Rodin、Hunyuan3D或本地文件)导入Tripo,以便与Tripo的后处理节点(纹理、绑定、转换)一起使用。推荐使用GLB格式:只有嵌入文件的纹理才能在导入时保留。请注意,为导入的模型添加纹理需要提供纹理提示词。",
|
||||
"display_name": "Tripo:导入模型",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "要导入的3D模型(GLB / FBX / OBJ / STL,最大150MB)。OBJ和STL文件不包含嵌入纹理。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo:多视图转模型",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "纹理对齐"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "用于纹理生成的可选文本引导。对于导入的模型(Tripo:导入模型),实际操作中需要提供,因为这些模型没有可用于推断颜色的源图像。"
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "纹理质量"
|
||||
},
|
||||
|
||||
@@ -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