mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-15 19:16:10 +00:00
Compare commits
1 Commits
cloud/1.46
...
fix/widget
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d40405d16 |
@@ -65,7 +65,6 @@
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unreachable": "error",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
"no-useless-rename": "off",
|
||||
@@ -74,14 +73,12 @@
|
||||
"import/namespace": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"vitest/expect-expect": "off",
|
||||
"vitest/no-conditional-expect": "off",
|
||||
"vitest/no-disabled-tests": "off",
|
||||
"vitest/no-standalone-expect": "off",
|
||||
"vitest/valid-title": "off",
|
||||
"vitest/require-to-throw-message": "off",
|
||||
"jest/expect-expect": "off",
|
||||
"jest/no-conditional-expect": "off",
|
||||
"jest/no-disabled-tests": "off",
|
||||
"jest/no-standalone-expect": "off",
|
||||
"jest/valid-title": "off",
|
||||
"typescript/no-this-alias": "off",
|
||||
"typescript/no-useless-default-assignment": "off",
|
||||
"typescript/no-unnecessary-parameter-property-assignment": "off",
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"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,16 +60,14 @@ 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',
|
||||
missingModelReferenceCount: 'missing-model-reference-count',
|
||||
missingModelUnsupportedSection:
|
||||
'missing-model-import-not-supported-section',
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-header-refresh',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
swapNodeGroupCount: 'swap-node-group-count',
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
AssetCreated,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { Asset } 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',
|
||||
@@ -40,62 +27,13 @@ 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
|
||||
@@ -150,216 +88,5 @@ 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,5 +1,4 @@
|
||||
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'
|
||||
@@ -12,18 +11,6 @@ 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(
|
||||
@@ -47,14 +34,15 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name and metadata', async ({ comfyPage }) => {
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(modelsGroup)).toBeVisible()
|
||||
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
|
||||
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
|
||||
})
|
||||
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
@@ -65,33 +53,32 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
)
|
||||
const expandButton = modelsGroup.getByTestId(
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
)
|
||||
await expect(expandButton.first()).toBeVisible()
|
||||
await expectReferenceBadge(modelsGroup, 2)
|
||||
await expandButton.first().click()
|
||||
|
||||
await expect(
|
||||
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(2)
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('/api/devtools/')
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
})
|
||||
|
||||
test.describe('OSS-specific', { tag: '@oss' }, () => {
|
||||
@@ -100,9 +87,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -115,7 +102,6 @@ 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,5 +1,4 @@
|
||||
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'
|
||||
@@ -9,18 +8,6 @@ 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')
|
||||
@@ -143,9 +130,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
@@ -169,7 +156,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -179,7 +168,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
@@ -261,17 +252,14 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -396,7 +384,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
@@ -449,7 +439,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
|
||||
@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
443
pnpm-lock.yaml
generated
443
pnpm-lock.yaml
generated
@@ -211,8 +211,8 @@ catalogs:
|
||||
specifier: ^4.16.2
|
||||
version: 4.16.2
|
||||
eslint-plugin-oxlint:
|
||||
specifier: 1.69.0
|
||||
version: 1.69.0
|
||||
specifier: 1.59.0
|
||||
version: 1.59.0
|
||||
eslint-plugin-playwright:
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
@@ -277,14 +277,14 @@ catalogs:
|
||||
specifier: ^2.12.9
|
||||
version: 2.12.9
|
||||
oxfmt:
|
||||
specifier: ^0.54.0
|
||||
version: 0.54.0
|
||||
specifier: ^0.44.0
|
||||
version: 0.44.0
|
||||
oxlint:
|
||||
specifier: ^1.69.0
|
||||
version: 1.69.0
|
||||
specifier: ^1.59.0
|
||||
version: 1.59.0
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.23.0
|
||||
version: 0.23.0
|
||||
specifier: ^0.20.0
|
||||
version: 0.20.0
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -717,13 +717,13 @@ importers:
|
||||
version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1)))(eslint@10.4.0(jiti@2.6.1))
|
||||
eslint-plugin-better-tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3)
|
||||
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3)
|
||||
eslint-plugin-import-x:
|
||||
specifier: 'catalog:'
|
||||
version: 4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))
|
||||
eslint-plugin-oxlint:
|
||||
specifier: 'catalog:'
|
||||
version: 1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0))
|
||||
version: 1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0))
|
||||
eslint-plugin-playwright:
|
||||
specifier: 'catalog:'
|
||||
version: 2.10.1(eslint@10.4.0(jiti@2.6.1))
|
||||
@@ -777,13 +777,13 @@ importers:
|
||||
version: 2.12.9
|
||||
oxfmt:
|
||||
specifier: 'catalog:'
|
||||
version: 0.54.0
|
||||
version: 0.44.0
|
||||
oxlint:
|
||||
specifier: 'catalog:'
|
||||
version: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
version: 1.59.0(oxlint-tsgolint@0.20.0)
|
||||
oxlint-tsgolint:
|
||||
specifier: 'catalog:'
|
||||
version: 0.23.0
|
||||
version: 0.20.0
|
||||
picocolors:
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.1
|
||||
@@ -2751,276 +2751,276 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.54.0':
|
||||
resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==}
|
||||
'@oxfmt/binding-android-arm-eabi@0.44.0':
|
||||
resolution: {integrity: sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==}
|
||||
'@oxfmt/binding-android-arm64@0.44.0':
|
||||
resolution: {integrity: sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==}
|
||||
'@oxfmt/binding-darwin-arm64@0.44.0':
|
||||
resolution: {integrity: sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.54.0':
|
||||
resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==}
|
||||
'@oxfmt/binding-darwin-x64@0.44.0':
|
||||
resolution: {integrity: sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.54.0':
|
||||
resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==}
|
||||
'@oxfmt/binding-freebsd-x64@0.44.0':
|
||||
resolution: {integrity: sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
|
||||
resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==}
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
|
||||
resolution: {integrity: sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
|
||||
resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==}
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
|
||||
resolution: {integrity: sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==}
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
|
||||
'@oxfmt/binding-linux-arm64-musl@0.44.0':
|
||||
resolution: {integrity: sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
|
||||
resolution: {integrity: sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
|
||||
'@oxfmt/binding-linux-x64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
|
||||
'@oxfmt/binding-linux-x64-musl@0.44.0':
|
||||
resolution: {integrity: sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
|
||||
'@oxfmt/binding-openharmony-arm64@0.44.0':
|
||||
resolution: {integrity: sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==}
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
|
||||
resolution: {integrity: sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==}
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
|
||||
resolution: {integrity: sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==}
|
||||
'@oxfmt/binding-win32-x64-msvc@0.44.0':
|
||||
resolution: {integrity: sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
|
||||
'@oxlint-tsgolint/darwin-arm64@0.20.0':
|
||||
resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.23.0':
|
||||
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
|
||||
'@oxlint-tsgolint/darwin-x64@0.20.0':
|
||||
resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
|
||||
'@oxlint-tsgolint/linux-arm64@0.20.0':
|
||||
resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.23.0':
|
||||
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
|
||||
'@oxlint-tsgolint/linux-x64@0.20.0':
|
||||
resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
|
||||
'@oxlint-tsgolint/win32-arm64@0.20.0':
|
||||
resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.23.0':
|
||||
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
|
||||
'@oxlint-tsgolint/win32-x64@0.20.0':
|
||||
resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.69.0':
|
||||
resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==}
|
||||
'@oxlint/binding-android-arm-eabi@1.59.0':
|
||||
resolution: {integrity: sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-android-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==}
|
||||
'@oxlint/binding-android-arm64@1.59.0':
|
||||
resolution: {integrity: sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==}
|
||||
'@oxlint/binding-darwin-arm64@1.59.0':
|
||||
resolution: {integrity: sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.69.0':
|
||||
resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==}
|
||||
'@oxlint/binding-darwin-x64@1.59.0':
|
||||
resolution: {integrity: sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.69.0':
|
||||
resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==}
|
||||
'@oxlint/binding-freebsd-x64@1.59.0':
|
||||
resolution: {integrity: sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
|
||||
resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==}
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
|
||||
resolution: {integrity: sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
|
||||
resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==}
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
|
||||
resolution: {integrity: sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==}
|
||||
'@oxlint/binding-linux-arm64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
|
||||
'@oxlint/binding-linux-arm64-musl@1.59.0':
|
||||
resolution: {integrity: sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
|
||||
'@oxlint/binding-linux-riscv64-musl@1.59.0':
|
||||
resolution: {integrity: sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
|
||||
'@oxlint/binding-linux-s390x-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
|
||||
'@oxlint/binding-linux-x64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
|
||||
'@oxlint/binding-linux-x64-musl@1.59.0':
|
||||
resolution: {integrity: sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
|
||||
'@oxlint/binding-openharmony-arm64@1.59.0':
|
||||
resolution: {integrity: sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==}
|
||||
'@oxlint/binding-win32-arm64-msvc@1.59.0':
|
||||
resolution: {integrity: sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==}
|
||||
'@oxlint/binding-win32-ia32-msvc@1.59.0':
|
||||
resolution: {integrity: sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==}
|
||||
'@oxlint/binding-win32-x64-msvc@1.59.0':
|
||||
resolution: {integrity: sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -5395,10 +5395,10 @@ packages:
|
||||
eslint-import-resolver-node:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-oxlint@1.69.0:
|
||||
resolution: {integrity: sha512-ryJT8Pqb3jgWhmQcKA/D98K6UckthAR70wPTBI4rOjcaKJ9nmQkysTLbTVVEcdzfT9mznV/2MKspBsCCpXm36w==}
|
||||
eslint-plugin-oxlint@1.59.0:
|
||||
resolution: {integrity: sha512-g0DR+xSsnUdyaMc2KAXvBVGWz5V4GwlAE1PM+ocKxl2Eg7YgOjkRLLbxgJ3bhYOhRLhD8F0X4DjJu2FSDvrvAg==}
|
||||
peerDependencies:
|
||||
oxlint: ~1.69.0
|
||||
oxlint: ~1.59.0
|
||||
|
||||
eslint-plugin-playwright@2.10.1:
|
||||
resolution: {integrity: sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==}
|
||||
@@ -7017,35 +7017,24 @@ packages:
|
||||
oxc-resolver@11.20.0:
|
||||
resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
|
||||
|
||||
oxfmt@0.54.0:
|
||||
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
|
||||
oxfmt@0.44.0:
|
||||
resolution: {integrity: sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
oxlint-tsgolint@0.20.0:
|
||||
resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.59.0:
|
||||
resolution: {integrity: sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
vite-plus: '*'
|
||||
peerDependenciesMeta:
|
||||
svelte:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
oxlint-tsgolint@0.23.0:
|
||||
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.69.0:
|
||||
resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=0.22.1'
|
||||
vite-plus: '*'
|
||||
oxlint-tsgolint: '>=0.18.0'
|
||||
peerDependenciesMeta:
|
||||
oxlint-tsgolint:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
@@ -8640,8 +8629,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.4:
|
||||
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
|
||||
vue-component-type-helpers@3.3.3:
|
||||
resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -10750,136 +10739,136 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.54.0':
|
||||
'@oxfmt/binding-android-arm-eabi@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.54.0':
|
||||
'@oxfmt/binding-android-arm64@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.54.0':
|
||||
'@oxfmt/binding-darwin-arm64@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.54.0':
|
||||
'@oxfmt/binding-darwin-x64@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.54.0':
|
||||
'@oxfmt/binding-freebsd-x64@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.54.0':
|
||||
'@oxfmt/binding-linux-arm64-musl@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-x64-gnu@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.54.0':
|
||||
'@oxfmt/binding-linux-x64-musl@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.54.0':
|
||||
'@oxfmt/binding-openharmony-arm64@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.54.0':
|
||||
'@oxfmt/binding-win32-x64-msvc@0.44.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.23.0':
|
||||
'@oxlint-tsgolint/darwin-arm64@0.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.23.0':
|
||||
'@oxlint-tsgolint/darwin-x64@0.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.23.0':
|
||||
'@oxlint-tsgolint/linux-arm64@0.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.23.0':
|
||||
'@oxlint-tsgolint/linux-x64@0.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.23.0':
|
||||
'@oxlint-tsgolint/win32-arm64@0.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.23.0':
|
||||
'@oxlint-tsgolint/win32-x64@0.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.69.0':
|
||||
'@oxlint/binding-android-arm-eabi@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm64@1.69.0':
|
||||
'@oxlint/binding-android-arm64@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.69.0':
|
||||
'@oxlint/binding-darwin-arm64@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.69.0':
|
||||
'@oxlint/binding-darwin-x64@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.69.0':
|
||||
'@oxlint/binding-freebsd-x64@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-arm64-gnu@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.69.0':
|
||||
'@oxlint/binding-linux-arm64-musl@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.69.0':
|
||||
'@oxlint/binding-linux-riscv64-musl@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-s390x-gnu@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-x64-gnu@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.69.0':
|
||||
'@oxlint/binding-linux-x64-musl@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.69.0':
|
||||
'@oxlint/binding-openharmony-arm64@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.69.0':
|
||||
'@oxlint/binding-win32-arm64-msvc@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.69.0':
|
||||
'@oxlint/binding-win32-ia32-msvc@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.69.0':
|
||||
'@oxlint/binding-win32-x64-msvc@1.59.0':
|
||||
optional: true
|
||||
|
||||
'@package-json/types@0.0.12': {}
|
||||
@@ -11323,7 +11312,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.4
|
||||
vue-component-type-helpers: 3.3.3
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -13459,7 +13448,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3):
|
||||
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@eslint/css-tree': 3.6.9
|
||||
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
|
||||
@@ -13472,7 +13461,7 @@ snapshots:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
eslint: 10.4.0(jiti@2.6.1)
|
||||
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -13494,10 +13483,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-oxlint@1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0)):
|
||||
eslint-plugin-oxlint@1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0)):
|
||||
dependencies:
|
||||
jsonc-parser: 3.3.1
|
||||
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
|
||||
|
||||
eslint-plugin-playwright@2.10.1(eslint@10.4.0(jiti@2.6.1)):
|
||||
dependencies:
|
||||
@@ -15464,61 +15453,61 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.20.0
|
||||
|
||||
oxfmt@0.54.0:
|
||||
oxfmt@0.44.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/binding-android-arm-eabi': 0.54.0
|
||||
'@oxfmt/binding-android-arm64': 0.54.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.54.0
|
||||
'@oxfmt/binding-darwin-x64': 0.54.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.54.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.54.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.54.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.54.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.54.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.54.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.54.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.54.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.54.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.54.0
|
||||
'@oxfmt/binding-android-arm-eabi': 0.44.0
|
||||
'@oxfmt/binding-android-arm64': 0.44.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.44.0
|
||||
'@oxfmt/binding-darwin-x64': 0.44.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.44.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.44.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.44.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.44.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.44.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.44.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.44.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.44.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.44.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.44.0
|
||||
|
||||
oxlint-tsgolint@0.23.0:
|
||||
oxlint-tsgolint@0.20.0:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.23.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.23.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.23.0
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.20.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.20.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.20.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.20.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.20.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.20.0
|
||||
|
||||
oxlint@1.69.0(oxlint-tsgolint@0.23.0):
|
||||
oxlint@1.59.0(oxlint-tsgolint@0.20.0):
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.69.0
|
||||
'@oxlint/binding-android-arm64': 1.69.0
|
||||
'@oxlint/binding-darwin-arm64': 1.69.0
|
||||
'@oxlint/binding-darwin-x64': 1.69.0
|
||||
'@oxlint/binding-freebsd-x64': 1.69.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.69.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.69.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.69.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.69.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.69.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.69.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.69.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.69.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.69.0
|
||||
oxlint-tsgolint: 0.23.0
|
||||
'@oxlint/binding-android-arm-eabi': 1.59.0
|
||||
'@oxlint/binding-android-arm64': 1.59.0
|
||||
'@oxlint/binding-darwin-arm64': 1.59.0
|
||||
'@oxlint/binding-darwin-x64': 1.59.0
|
||||
'@oxlint/binding-freebsd-x64': 1.59.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.59.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.59.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.59.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.59.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.59.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.59.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.59.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.59.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.59.0
|
||||
oxlint-tsgolint: 0.20.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
@@ -17469,7 +17458,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.4: {}
|
||||
vue-component-type-helpers@3.3.3: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -79,7 +79,7 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.2
|
||||
eslint-plugin-oxlint: 1.69.0
|
||||
eslint-plugin-oxlint: 1.59.0
|
||||
eslint-plugin-playwright: ^2.10.1
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
@@ -101,9 +101,9 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
:source-format="sourceFormat"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@@ -167,7 +166,6 @@ const {
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
sourceFormat,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
|
||||
<ExportControls
|
||||
v-if="showExportControls"
|
||||
:source-format="sourceFormat"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
@@ -135,8 +134,7 @@ const {
|
||||
canUseHdri = true,
|
||||
canUseBackgroundImage = true,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
@@ -145,7 +143,6 @@ const {
|
||||
canUseBackgroundImage?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -59,7 +59,6 @@ function buildViewerStub() {
|
||||
canUseGizmo: ref(true),
|
||||
canUseLighting: ref(true),
|
||||
canExport: ref(true),
|
||||
sourceFormat: ref<string | null>(null),
|
||||
materialModes: ref(['original', 'normal', 'wireframe']),
|
||||
animations: ref<Array<{ name: string; index: number }>>([]),
|
||||
playing: ref(false),
|
||||
|
||||
@@ -82,10 +82,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
|
||||
<ExportControls
|
||||
:source-format="viewer.sourceFormat.value"
|
||||
@export-model="viewer.exportModel"
|
||||
/>
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,9 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ExportControls, {
|
||||
props: { onExportModel, sourceFormat },
|
||||
props: { onExportModel },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
@@ -66,23 +63,6 @@ describe('ExportControls', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers only the source format for direct-export files (e.g. ply)', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel, 'ply')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
|
||||
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: label })
|
||||
).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'PLY' }))
|
||||
expect(onExportModel).toHaveBeenCalledWith('ply')
|
||||
})
|
||||
|
||||
it('hides the popup when a click happens outside the trigger', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
|
||||
@@ -35,14 +35,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
|
||||
const { sourceFormat = null } = defineProps<{
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
@@ -50,7 +45,12 @@ const emit = defineEmits<{
|
||||
|
||||
const showExportFormats = ref(false)
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
function toggleExportFormats() {
|
||||
showExportFormats.value = !showExportFormats.value
|
||||
|
||||
@@ -72,12 +72,9 @@ const i18n = createI18n({
|
||||
messages: { en: { load3d: { export: 'Export' } } }
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ViewerExportControls, {
|
||||
props: { onExportModel, sourceFormat },
|
||||
props: { onExportModel },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
@@ -117,32 +114,4 @@ describe('ViewerExportControls', () => {
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('glb')
|
||||
})
|
||||
|
||||
it('offers only the source format for direct-export files (e.g. spz)', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel, 'spz')
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('spz')
|
||||
})
|
||||
|
||||
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user, rerender } = renderComponent(onExportModel, null)
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
|
||||
expect(select.value).toBe('obj')
|
||||
|
||||
await rerender({ onExportModel, sourceFormat: 'ply' })
|
||||
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('ply')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
@@ -34,30 +34,20 @@ import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
|
||||
const { sourceFormat = null } = defineProps<{
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
const exportFormat = ref('obj')
|
||||
|
||||
watch(
|
||||
exportFormats,
|
||||
(formats) => {
|
||||
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
|
||||
exportFormat.value = formats[0]?.value ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const exportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
|
||||
@@ -539,7 +539,7 @@ describe('TabErrors.vue', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -557,8 +557,11 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-header-refresh')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,10 +94,9 @@
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
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')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
@@ -113,6 +112,7 @@
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
{{ t('rightSidePanel.missingModels.refresh') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
@@ -246,6 +246,7 @@
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
@@ -300,9 +301,11 @@ 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'
|
||||
@@ -316,6 +319,7 @@ 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'
|
||||
@@ -343,6 +347,7 @@ 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 } =
|
||||
@@ -366,6 +371,12 @@ 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' &&
|
||||
@@ -452,13 +463,20 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const missingModelDownloadableModels = computed(() => {
|
||||
if (isCloud) return []
|
||||
|
||||
return getDownloadableModels(missingModelGroups.value)
|
||||
})
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
() =>
|
||||
!isCloud &&
|
||||
missingModelGroups.value.length > 0 &&
|
||||
missingModelDownloadableModels.value.length === 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,6 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
|
||||
@@ -169,7 +169,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const isPreview = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const sourceFormat = ref<string | null>(null)
|
||||
const canFitToViewer = ref(true)
|
||||
const canCenterCameraOnModel = ref(false)
|
||||
const canUseGizmo = ref(true)
|
||||
@@ -906,7 +905,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
sourceFormat.value = load3d?.getSourceFormat() ?? null
|
||||
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
|
||||
const caps = load3d?.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps?.fitToViewer ?? true
|
||||
@@ -1072,7 +1070,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
|
||||
@@ -130,7 +130,6 @@ describe('useLoad3dViewer', () => {
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
@@ -619,7 +618,7 @@ describe('useLoad3dViewer', () => {
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: true,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
})
|
||||
@@ -631,7 +630,7 @@ describe('useLoad3dViewer', () => {
|
||||
expect.stringContaining('dropped.splat')
|
||||
)
|
||||
expect(viewer.canUseLighting.value).toBe(false)
|
||||
expect(viewer.canExport.value).toBe(true)
|
||||
expect(viewer.canExport.value).toBe(false)
|
||||
expect(viewer.isSplatModel.value).toBe(true)
|
||||
expect([...viewer.materialModes.value]).toEqual([])
|
||||
})
|
||||
|
||||
@@ -83,7 +83,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const isStandaloneMode = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const sourceFormat = ref<string | null>(null)
|
||||
const canFitToViewer = ref(true)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
@@ -97,7 +96,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const captureAdapterFlags = (source: Load3d) => {
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
sourceFormat.value = source.getSourceFormat()
|
||||
const caps = source.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps.fitToViewer
|
||||
canUseGizmo.value = caps.gizmoTransform
|
||||
@@ -841,7 +839,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isStandaloneMode,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
|
||||
@@ -12,17 +12,13 @@ const {
|
||||
exportGLBMock,
|
||||
exportOBJMock,
|
||||
exportSTLMock,
|
||||
exportFBXMock,
|
||||
exportDirectMock,
|
||||
detectFormatFromURLMock
|
||||
exportFBXMock
|
||||
} = vi.hoisted(() => ({
|
||||
cloneSkinnedMock: vi.fn(),
|
||||
exportGLBMock: vi.fn(),
|
||||
exportOBJMock: vi.fn(),
|
||||
exportSTLMock: vi.fn(),
|
||||
exportFBXMock: vi.fn(),
|
||||
exportDirectMock: vi.fn(),
|
||||
detectFormatFromURLMock: vi.fn()
|
||||
exportFBXMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
|
||||
@@ -34,9 +30,7 @@ vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
|
||||
exportGLB: exportGLBMock,
|
||||
exportOBJ: exportOBJMock,
|
||||
exportSTL: exportSTLMock,
|
||||
exportFBX: exportFBXMock,
|
||||
exportDirect: exportDirectMock,
|
||||
detectFormatFromURL: detectFormatFromURLMock
|
||||
exportFBX: exportFBXMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1178,56 +1172,5 @@ describe('Load3d', () => {
|
||||
'Unsupported export format: xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads the source file directly for direct-export formats', async () => {
|
||||
exportDirectMock.mockReset()
|
||||
detectFormatFromURLMock.mockReturnValue('ply')
|
||||
const model = new THREE.Object3D()
|
||||
setupForExport({
|
||||
currentModel: model,
|
||||
originalFileName: 'cloud',
|
||||
originalURL: 'http://example.com/api/view?filename=cloud.ply'
|
||||
})
|
||||
|
||||
await ctx.load3d.exportModel('ply')
|
||||
|
||||
expect(exportDirectMock).toHaveBeenCalledWith(
|
||||
'http://example.com/api/view?filename=cloud.ply',
|
||||
'cloud.ply',
|
||||
'ply'
|
||||
)
|
||||
expect(exportGLBMock).not.toHaveBeenCalled()
|
||||
expect(exportOBJMock).not.toHaveBeenCalled()
|
||||
expect(cloneSkinnedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a direct export when the requested format differs from the source', async () => {
|
||||
exportDirectMock.mockReset()
|
||||
detectFormatFromURLMock.mockReturnValue('spz')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setupForExport({
|
||||
currentModel: new THREE.Object3D(),
|
||||
originalFileName: 'scene',
|
||||
originalURL: 'http://example.com/api/view?filename=scene.spz'
|
||||
})
|
||||
|
||||
await expect(ctx.load3d.exportModel('ply')).rejects.toThrow(
|
||||
'Cannot export ply without converting from the loaded spz source'
|
||||
)
|
||||
expect(exportDirectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getSourceFormat derives the extension from the original URL', () => {
|
||||
detectFormatFromURLMock.mockReturnValue('spz')
|
||||
setupForExport({
|
||||
currentModel: new THREE.Object3D(),
|
||||
originalURL: 'http://example.com/api/view?filename=scene.spz'
|
||||
})
|
||||
|
||||
expect(ctx.load3d.getSourceFormat()).toBe('spz')
|
||||
expect(detectFormatFromURLMock).toHaveBeenCalledWith(
|
||||
'http://example.com/api/view?filename=scene.spz'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { GizmoManager } from './GizmoManager'
|
||||
import type { HDRIManager } from './HDRIManager'
|
||||
import type { LightingManager } from './LightingManager'
|
||||
import type { LoaderManager } from './LoaderManager'
|
||||
import { DIRECT_EXPORT_FORMATS } from './constants'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
|
||||
@@ -360,27 +359,6 @@ class Load3d {
|
||||
const exportMessage = `Exporting as ${format.toUpperCase()}...`
|
||||
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
|
||||
|
||||
const originalFileName = this.modelManager.originalFileName || 'model'
|
||||
const filename = `${originalFileName}.${format}`
|
||||
const originalURL = this.modelManager.originalURL
|
||||
|
||||
if (DIRECT_EXPORT_FORMATS.has(format)) {
|
||||
try {
|
||||
if (this.getSourceFormat() !== format) {
|
||||
throw new Error(
|
||||
`Cannot export ${format} without converting from the loaded ${this.getSourceFormat() ?? 'unknown'} source`
|
||||
)
|
||||
}
|
||||
await ModelExporter.exportDirect(originalURL, filename, format)
|
||||
} catch (error) {
|
||||
console.error(`Error exporting model as ${format}:`, error)
|
||||
throw error
|
||||
} finally {
|
||||
this.eventManager.emitEvent('exportLoadingEnd', null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const source = this.modelManager.currentModel
|
||||
const savedPos = source.position.clone()
|
||||
const savedRot = source.rotation.clone()
|
||||
@@ -406,6 +384,11 @@ class Load3d {
|
||||
? Object.assign(cloneSkinned(source), { animations: clips })
|
||||
: source.clone()
|
||||
|
||||
const originalFileName = this.modelManager.originalFileName || 'model'
|
||||
const filename = `${originalFileName}.${format}`
|
||||
|
||||
const originalURL = this.modelManager.originalURL
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
switch (format) {
|
||||
@@ -416,7 +399,7 @@ class Load3d {
|
||||
await ModelExporter.exportOBJ(model, filename, originalURL)
|
||||
break
|
||||
case 'stl':
|
||||
await ModelExporter.exportSTL(model, filename, originalURL)
|
||||
;(await ModelExporter.exportSTL(model, filename), originalURL)
|
||||
break
|
||||
case 'fbx':
|
||||
await ModelExporter.exportFBX(model, filename, originalURL)
|
||||
@@ -438,12 +421,6 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
getSourceFormat(): string | null {
|
||||
const url = this.modelManager.originalURL
|
||||
if (!url) return null
|
||||
return ModelExporter.detectFormatFromURL(url)
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.sceneManager.setBackgroundColor(color)
|
||||
|
||||
|
||||
@@ -338,35 +338,6 @@ describe('ModelExporter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportDirect', () => {
|
||||
it('downloads the original source file unchanged', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportDirect(
|
||||
'http://example.com/api/view?filename=src.ply',
|
||||
'out.ply',
|
||||
'ply'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.ply', blob)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('throws without toasting when there is no source URL, leaving the alert to the caller', async () => {
|
||||
await expect(
|
||||
ModelExporter.exportDirect(null, 'out.spz', 'spz')
|
||||
).rejects.toThrow('No source file available to export as spz')
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
expect(addAlertMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportFBX', () => {
|
||||
it('uses the direct-URL fast path for matching .fbx URLs', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
|
||||
@@ -184,18 +184,6 @@ export class ModelExporter {
|
||||
}
|
||||
}
|
||||
|
||||
static async exportDirect(
|
||||
originalURL: string | null | undefined,
|
||||
filename: string,
|
||||
format: string
|
||||
): Promise<void> {
|
||||
if (!originalURL) {
|
||||
throw new Error(`No source file available to export as ${format}`)
|
||||
}
|
||||
|
||||
return ModelExporter.downloadFromURL(originalURL, filename)
|
||||
}
|
||||
|
||||
private static saveArrayBuffer(buffer: ArrayBuffer, filename: string): void {
|
||||
const blob = new Blob([buffer], { type: 'application/octet-stream' })
|
||||
downloadBlob(filename, blob)
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('SplatModelAdapter', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect(adapter.kind).toBe('splat')
|
||||
expect(adapter.capabilities.lighting).toBe(false)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect(adapter.capabilities.exportable).toBe(false)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class SplatModelAdapter implements ModelAdapter {
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: true,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getExportFormatOptions } from './constants'
|
||||
|
||||
describe('getExportFormatOptions', () => {
|
||||
it('returns the convertible mesh formats for mesh sources', () => {
|
||||
expect(getExportFormatOptions('glb').map((o) => o.value)).toEqual([
|
||||
'glb',
|
||||
'obj',
|
||||
'stl',
|
||||
'fbx'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the convertible mesh formats when the source is unknown', () => {
|
||||
expect(getExportFormatOptions(null).map((o) => o.value)).toEqual([
|
||||
'glb',
|
||||
'obj',
|
||||
'stl',
|
||||
'fbx'
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['ply', 'spz', 'splat', 'ksplat'])(
|
||||
'offers only the source format for direct-export type %s',
|
||||
(format) => {
|
||||
expect(getExportFormatOptions(format)).toEqual([
|
||||
{ label: format.toUpperCase(), value: format }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('matches direct-export formats case-insensitively', () => {
|
||||
expect(getExportFormatOptions('PLY')).toEqual([
|
||||
{ label: 'PLY', value: 'ply' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -24,27 +24,3 @@ export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
|
||||
].join(',')
|
||||
|
||||
export const LOAD3D_NONE_MODEL = 'none'
|
||||
|
||||
export const DIRECT_EXPORT_FORMATS = new Set(['ply', 'spz', 'splat', 'ksplat'])
|
||||
|
||||
export interface ExportFormatOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const CONVERTIBLE_EXPORT_FORMAT_OPTIONS: ExportFormatOption[] = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
export function getExportFormatOptions(
|
||||
sourceFormat: string | null | undefined
|
||||
): ExportFormatOption[] {
|
||||
const format = sourceFormat?.toLowerCase()
|
||||
if (format && DIRECT_EXPORT_FORMATS.has(format)) {
|
||||
return [{ label: format.toUpperCase(), value: format }]
|
||||
}
|
||||
return CONVERTIBLE_EXPORT_FORMAT_OPTIONS
|
||||
}
|
||||
|
||||
@@ -587,6 +587,34 @@ describe('LGraphNode', () => {
|
||||
expect(node.widgets![0].value).toBe(1)
|
||||
expect(node.widgets![1].value).toBe(100)
|
||||
})
|
||||
|
||||
test('round-trips values across a serialize:false widget in the middle', () => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.serialize_widgets = true
|
||||
node.addWidget('number', 'a', 1, null)
|
||||
node.addWidget('number', 'shim', 0, null)
|
||||
node.addWidget('number', 'b', 2, null)
|
||||
node.widgets![1].serialize = false
|
||||
|
||||
const serialized = node.serialize()
|
||||
// Dense: the middle serialize:false widget must not leave a gap.
|
||||
expect(serialized.widgets_values).toEqual([1, 2])
|
||||
|
||||
node.widgets![0].value = 0
|
||||
node.widgets![2].value = 0
|
||||
node.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
properties: {},
|
||||
widgets_values: serialized.widgets_values
|
||||
})
|
||||
)
|
||||
expect(node.widgets![0].value).toBe(1)
|
||||
expect(node.widgets![2].value).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputSlotPos', () => {
|
||||
|
||||
@@ -973,14 +973,15 @@ export class LGraphNode
|
||||
const { widgets } = this
|
||||
if (widgets && this.serialize_widgets) {
|
||||
o.widgets_values = []
|
||||
for (const [i, widget] of widgets.entries()) {
|
||||
for (const widget of widgets) {
|
||||
if (widget.serialize === false) continue
|
||||
const val = widget?.value
|
||||
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
|
||||
o.widgets_values[i] =
|
||||
o.widgets_values.push(
|
||||
val != null && typeof val === 'object'
|
||||
? JSON.parse(JSON.stringify(val))
|
||||
: (val ?? null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,6 +1402,7 @@ export class LGraphNode
|
||||
|
||||
default:
|
||||
return false
|
||||
break
|
||||
}
|
||||
this.mode = modeTo
|
||||
return true
|
||||
|
||||
@@ -3091,13 +3091,6 @@
|
||||
"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",
|
||||
@@ -3650,17 +3643,32 @@
|
||||
"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...",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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,50 +22,16 @@
|
||||
</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 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 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>
|
||||
<SingleSelect
|
||||
v-model="modelValue"
|
||||
@@ -75,37 +41,23 @@
|
||||
: $t('assetBrowser.modelTypeSelectorPlaceholder')
|
||||
"
|
||||
:options="modelTypes"
|
||||
:disabled="isLoading || isMissingModelResolution"
|
||||
:disabled="isLoading"
|
||||
: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'
|
||||
|
||||
const { uploadContext } = defineProps<{
|
||||
defineProps<{
|
||||
metadata?: AssetMetadata
|
||||
previewImage?: string
|
||||
uploadContext?: UploadModelDialogContext
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
@@ -113,27 +65,4 @@ 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,7 +17,6 @@
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
:preview-image="wizardData.previewImage"
|
||||
:upload-context="uploadContext"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
@@ -25,7 +24,6 @@
|
||||
v-else-if="currentStep === 3 && uploadStatus != null"
|
||||
:result="uploadStatus"
|
||||
:error="uploadError"
|
||||
:type-mismatch="uploadTypeMismatch"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
:preview-image="wizardData.previewImage"
|
||||
@@ -41,7 +39,6 @@
|
||||
:can-fetch-metadata="canFetchMetadata"
|
||||
:can-upload-model="canUploadModel"
|
||||
:upload-status="uploadStatus"
|
||||
:can-import-another="!isMissingModelResolution"
|
||||
@back="goToPreviousStep"
|
||||
@fetch-metadata="handleFetchMetadata"
|
||||
@upload="handleUploadModel"
|
||||
@@ -52,47 +49,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { 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 { uploadContext } = defineProps<{
|
||||
uploadContext?: UploadModelDialogContext
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload-success': [result: UploadModelSuccess]
|
||||
'upload-success': []
|
||||
}>()
|
||||
|
||||
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,
|
||||
@@ -101,18 +80,16 @@ const {
|
||||
uploadModel,
|
||||
goToPreviousStep,
|
||||
resetWizard
|
||||
} = useUploadModelWizard(modelTypes, {
|
||||
requiredModelType: requiredModelType.value
|
||||
})
|
||||
} = useUploadModelWizard(modelTypes)
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
await fetchMetadata()
|
||||
}
|
||||
|
||||
async function handleUploadModel() {
|
||||
const result = await uploadModel()
|
||||
if (result) {
|
||||
emit('upload-success', result)
|
||||
const success = await uploadModel()
|
||||
if (success) {
|
||||
emit('upload-success')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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,7 +73,6 @@
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-import-another-button"
|
||||
:disabled="!canImportAnother"
|
||||
@click="emit('importAnother')"
|
||||
>
|
||||
{{ $t('assetBrowser.importAnother') }}
|
||||
@@ -91,24 +90,6 @@
|
||||
}}
|
||||
</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"
|
||||
@@ -132,14 +113,13 @@ import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
|
||||
const showCivitaiHelp = ref(false)
|
||||
const showHuggingFaceHelp = ref(false)
|
||||
|
||||
const { canImportAnother = true } = defineProps<{
|
||||
defineProps<{
|
||||
currentStep: number
|
||||
isFetchingMetadata: boolean
|
||||
isUploading: boolean
|
||||
canFetchMetadata: boolean
|
||||
canUploadModel: boolean
|
||||
uploadStatus?: 'processing' | 'success' | 'error'
|
||||
canImportAnother?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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,12 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col gap-6 text-sm text-muted-foreground',
|
||||
isTypeMismatchError && 'min-h-full justify-center'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
|
||||
<!-- Processing State (202 async download in progress) -->
|
||||
<div v-if="result === 'processing'" class="flex flex-col gap-2">
|
||||
<p class="m-0 font-bold">
|
||||
@@ -74,51 +67,8 @@
|
||||
v-else-if="result === 'error'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<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">
|
||||
<i class="icon-[lucide--x-circle] text-6xl text-error" />
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadFailed') }}
|
||||
</p>
|
||||
@@ -131,26 +81,13 @@
|
||||
</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'
|
||||
|
||||
const { typeMismatch } = defineProps<{
|
||||
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,28 +3,17 @@ 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?: (result: UploadModelSuccess) => Promise<unknown> | void,
|
||||
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
|
||||
onUploadSuccess?: () => Promise<unknown> | void
|
||||
) {
|
||||
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({
|
||||
@@ -44,9 +33,8 @@ export function useModelUpload(
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
uploadContext: resolveUploadContext(),
|
||||
onUploadSuccess: async (result: UploadModelSuccess) => {
|
||||
await onUploadSuccess?.(result)
|
||||
onUploadSuccess: async () => {
|
||||
await onUploadSuccess?.()
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -49,52 +45,18 @@ 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')
|
||||
@@ -109,18 +71,11 @@ describe('useUploadModelWizard', () => {
|
||||
}
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
|
||||
|
||||
const wizard = setupUploadModelWizard(modelTypes)
|
||||
const wizard = useUploadModelWizard(modelTypes)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).toEqual({
|
||||
filename: 'model',
|
||||
modelType: 'checkpoints',
|
||||
taskId: 'task-123',
|
||||
status: 'processing'
|
||||
})
|
||||
await wizard.uploadModel()
|
||||
|
||||
expect(wizard.uploadStatus.value).toBe('processing')
|
||||
|
||||
@@ -163,7 +118,7 @@ describe('useUploadModelWizard', () => {
|
||||
}
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
|
||||
|
||||
const wizard = setupUploadModelWizard(modelTypes)
|
||||
const wizard = useUploadModelWizard(modelTypes)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
@@ -214,7 +169,7 @@ describe('useUploadModelWizard', () => {
|
||||
}
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
|
||||
|
||||
const wizard = setupUploadModelWizard(modelTypes)
|
||||
const wizard = useUploadModelWizard(modelTypes)
|
||||
wizard.wizardData.value.url = 'https://civitai.red/models/12345'
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
@@ -223,160 +178,4 @@ 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,13 +5,9 @@ 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 {
|
||||
AssetItem,
|
||||
AssetMetadata
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import type { 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'
|
||||
@@ -30,54 +26,16 @@ interface ModelTypeOption {
|
||||
value: string
|
||||
}
|
||||
|
||||
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 = {}
|
||||
) {
|
||||
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
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>({
|
||||
@@ -86,10 +44,7 @@ export function useUploadModelWizard(
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string | undefined>(requiredModelType)
|
||||
const resolvedModelType = computed(
|
||||
() => requiredModelType ?? selectedModelType.value
|
||||
)
|
||||
const selectedModelType = ref<string>()
|
||||
|
||||
const importSources: ImportSource[] = [
|
||||
civitaiImportSource,
|
||||
@@ -110,29 +65,16 @@ export function useUploadModelWizard(
|
||||
() => 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 !!resolvedModelType.value
|
||||
return !!selectedModelType.value
|
||||
})
|
||||
|
||||
async function fetchMetadata() {
|
||||
@@ -186,9 +128,7 @@ export function useUploadModelWizard(
|
||||
wizardData.value.previewImage = metadata.preview_image
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (requiredModelType) {
|
||||
selectedModelType.value = requiredModelType
|
||||
} else if (metadata.tags && metadata.tags.length > 0) {
|
||||
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) =>
|
||||
@@ -243,10 +183,10 @@ export function useUploadModelWizard(
|
||||
}
|
||||
|
||||
async function refreshModelCaches() {
|
||||
if (!resolvedModelType.value) return
|
||||
if (!selectedModelType.value) return
|
||||
|
||||
const providers = modelToNodeStore.getAllNodeProviders(
|
||||
resolvedModelType.value
|
||||
selectedModelType.value
|
||||
)
|
||||
const results = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
@@ -263,61 +203,24 @@ export function useUploadModelWizard(
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
async function uploadModel(): Promise<boolean> {
|
||||
if (isUploading.value) return false
|
||||
if (!canUploadModel.value) {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
const source = detectedSource.value
|
||||
if (!source) {
|
||||
uploadError.value = t('assetBrowser.noValidSourceDetected')
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
uploadTypeMismatch.value = null
|
||||
let uploadSuccess: UploadModelSuccess | null = null
|
||||
|
||||
try {
|
||||
const modelType = resolvedModelType.value
|
||||
const tags = modelType ? ['models', modelType] : ['models']
|
||||
const tags = selectedModelType.value
|
||||
? ['models', selectedModelType.value]
|
||||
: ['models']
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
@@ -327,7 +230,7 @@ export function useUploadModelWizard(
|
||||
const userMetadata = {
|
||||
source: source.type,
|
||||
source_url: wizardData.value.url,
|
||||
model_type: modelType
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
|
||||
const result = await assetService.uploadAssetAsync({
|
||||
@@ -338,20 +241,14 @@ export function useUploadModelWizard(
|
||||
})
|
||||
|
||||
if (result.type === 'async' && result.task.status !== 'completed') {
|
||||
if (modelType) {
|
||||
if (selectedModelType.value) {
|
||||
assetDownloadStore.trackDownload(
|
||||
result.task.task_id,
|
||||
modelType,
|
||||
selectedModelType.value,
|
||||
filename
|
||||
)
|
||||
}
|
||||
uploadStatus.value = 'processing'
|
||||
uploadSuccess = {
|
||||
filename,
|
||||
modelType,
|
||||
taskId: result.task.task_id,
|
||||
status: 'processing'
|
||||
}
|
||||
|
||||
stopAsyncWatch?.()
|
||||
let resolved = false
|
||||
@@ -391,24 +288,8 @@ export function useUploadModelWizard(
|
||||
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) {
|
||||
@@ -420,7 +301,7 @@ export function useUploadModelWizard(
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
return uploadSuccess
|
||||
return uploadStatus.value !== 'error'
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
@@ -437,13 +318,12 @@ export function useUploadModelWizard(
|
||||
isUploading.value = false
|
||||
uploadStatus.value = undefined
|
||||
uploadError.value = ''
|
||||
uploadTypeMismatch.value = null
|
||||
wizardData.value = {
|
||||
url: '',
|
||||
name: '',
|
||||
tags: []
|
||||
}
|
||||
selectedModelType.value = requiredModelType
|
||||
selectedModelType.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -453,7 +333,6 @@ export function useUploadModelWizard(
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
uploadTypeMismatch,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ async function setupMocks(
|
||||
const {
|
||||
nodeProvider = createMockNodeProvider(),
|
||||
canvasCenter = [100, 200],
|
||||
activeSubgraph,
|
||||
activeSubgraph = undefined,
|
||||
createdNode = await createMockNode()
|
||||
} = overrides
|
||||
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { render, screen } 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
|
||||
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'],
|
||||
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'],
|
||||
emits: ['locate-model']
|
||||
}
|
||||
}))
|
||||
@@ -48,7 +35,21 @@ import MissingModelCard from './MissingModelCard.vue'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages },
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -105,6 +106,7 @@ function makeGroup(
|
||||
function mountCard(
|
||||
props: Partial<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}> = {},
|
||||
onLocateModel?: (nodeId: string) => void
|
||||
) {
|
||||
@@ -112,6 +114,7 @@ function mountCard(
|
||||
return render(MissingModelCard, {
|
||||
props: {
|
||||
missingModelGroups: [makeGroup()],
|
||||
showNodeIdBadge: false,
|
||||
...props,
|
||||
...(onLocateModel ? { onLocateModel } : {})
|
||||
},
|
||||
@@ -121,115 +124,62 @@ 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('passes the model directory to rows', () => {
|
||||
mockIsCloud.value = false
|
||||
mountCard({
|
||||
it('renders directory name in category header', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: 'loras' })]
|
||||
})
|
||||
expect(getRows()[0].getAttribute('data-directory')).toBe('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)')
|
||||
})
|
||||
|
||||
it('renders correct number of MissingModelRow components', () => {
|
||||
mountCard({
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({
|
||||
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(getRows()).toHaveLength(3)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelectorAll('.model-row')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('flattens multiple groups into rows', () => {
|
||||
mockIsCloud.value = false
|
||||
mountCard({
|
||||
it('renders multiple groups', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: 'checkpoints' }),
|
||||
makeGroup({ directory: '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()
|
||||
expect(container.textContent).toContain('checkpoints')
|
||||
expect(container.textContent).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders zero rows when missingModelGroups is empty', () => {
|
||||
mountCard({ missingModelGroups: [] })
|
||||
expect(getRows()).toHaveLength(0)
|
||||
const { container } = mountCard({ missingModelGroups: [] })
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelectorAll('.model-row')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hides bulk actions in cloud', () => {
|
||||
@@ -241,6 +191,43 @@ 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', () => {
|
||||
@@ -264,43 +251,79 @@ describe('MissingModelCard (OSS)', () => {
|
||||
})
|
||||
|
||||
it('shows directory name instead of "Import Not Supported" for unsupported groups', () => {
|
||||
mountCard({
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: 'checkpoints', isAssetSupported: false })
|
||||
]
|
||||
})
|
||||
expect(getRows()[0].getAttribute('data-directory')).toBe('checkpoints')
|
||||
expect(container.textContent).toContain('checkpoints')
|
||||
expect(container.textContent).not.toContain('Import Not Supported')
|
||||
})
|
||||
|
||||
it('passes null directory for unknown category rows in OSS', () => {
|
||||
mountCard({
|
||||
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({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: null, isAssetSupported: false })
|
||||
]
|
||||
})
|
||||
expect(getRows()[0].hasAttribute('data-directory')).toBe(false)
|
||||
expect(container.textContent).toContain('Unknown Category')
|
||||
expect(container.textContent).not.toContain('Import Not Supported')
|
||||
})
|
||||
|
||||
it('shows Download all at the bottom when one model is downloadable', () => {
|
||||
it('shows bulk actions when one model is downloadable', () => {
|
||||
mountCard({
|
||||
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
|
||||
})
|
||||
|
||||
const actions = screen.getByTestId('missing-model-actions')
|
||||
expect(actions).toBeVisible()
|
||||
expect(
|
||||
within(actions).getByRole('button', { name: /Download all/ })
|
||||
).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('hides Download all when no model is downloadable', () => {
|
||||
it('hides bulk actions when no model is downloadable', () => {
|
||||
mountCard()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Download all/ })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
screen.queryByRole('button', { name: 'Refresh' })
|
||||
).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,49 +1,9 @@
|
||||
<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 pt-2"
|
||||
class="flex items-center gap-2 border-b border-interface-stroke py-2"
|
||||
>
|
||||
<Button
|
||||
data-testid="missing-model-download-all"
|
||||
@@ -55,6 +15,100 @@
|
||||
<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>
|
||||
@@ -66,28 +120,15 @@ 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'
|
||||
|
||||
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<{
|
||||
const { missingModelGroups, showNodeIdBadge } = defineProps<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -97,27 +138,6 @@ 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 []
|
||||
|
||||
@@ -139,37 +159,7 @@ function downloadAllModels() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
function handleRefreshClick() {
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<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>
|
||||
@@ -1,460 +0,0 @@
|
||||
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,11 +1,72 @@
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<Button
|
||||
v-if="hasMultipleReferences"
|
||||
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"
|
||||
data-testid="missing-model-expand"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
size="icon-sm"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingModels.collapseNodes')
|
||||
@@ -14,193 +75,131 @@
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-180'
|
||||
)
|
||||
"
|
||||
@click="handleToggleExpand"
|
||||
@click="toggleModelExpand(modelKey)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-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>
|
||||
<ul
|
||||
v-if="showReferenceList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-0.5 p-0',
|
||||
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
|
||||
)
|
||||
"
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
>
|
||||
<li
|
||||
<div
|
||||
v-for="ref in model.referencingNodes"
|
||||
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
|
||||
class="min-w-0"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
|
||||
import { computed, onMounted } 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
|
||||
getNodeDisplayLabel,
|
||||
getComboValue
|
||||
} from '@/platform/missingModel/composables/useMissingModelInteractions'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
@@ -213,16 +212,11 @@ import {
|
||||
} from '@/platform/missingModel/missingModelDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const {
|
||||
model,
|
||||
directory,
|
||||
isAssetSupported,
|
||||
canCloudImport = true
|
||||
} = defineProps<{
|
||||
const { model, directory, isAssetSupported } = defineProps<{
|
||||
model: MissingModelViewModel
|
||||
directory: string | null
|
||||
showNodeIdBadge: boolean
|
||||
isAssetSupported: boolean
|
||||
canCloudImport?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -237,117 +231,21 @@ const modelKey = computed(() =>
|
||||
)
|
||||
|
||||
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
|
||||
const isUnknownCategory = computed(() => directory === null)
|
||||
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 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 } = 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
|
||||
)
|
||||
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
|
||||
storeToRefs(store)
|
||||
|
||||
onMounted(() => {
|
||||
if (isCloud) return
|
||||
|
||||
const url = model.representative.url
|
||||
if (url && !store.fileSizes[url]) {
|
||||
fetchModelMetadata(url)
|
||||
@@ -365,6 +263,27 @@ 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) {
|
||||
@@ -377,53 +296,17 @@ function handleDownload() {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
const {
|
||||
toggleModelExpand,
|
||||
isModelExpanded,
|
||||
getComboOptions,
|
||||
handleComboSelect,
|
||||
isSelectionConfirmable,
|
||||
cancelLibrarySelect,
|
||||
confirmLibrarySelect,
|
||||
getTypeMismatch,
|
||||
getDownloadStatus
|
||||
} = useMissingModelInteractions()
|
||||
|
||||
function handleLibrarySelect() {
|
||||
confirmLibrarySelect(
|
||||
|
||||
108
src/platform/missingModel/components/MissingModelStatusCard.vue
Normal file
108
src/platform/missingModel/components/MissingModelStatusCard.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
@@ -0,0 +1,184 @@
|
||||
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, '')
|
||||
})
|
||||
})
|
||||
})
|
||||
135
src/platform/missingModel/components/MissingModelUrlInput.vue
Normal file
135
src/platform/missingModel/components/MissingModelUrlInput.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<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,26 +1,38 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import type { App } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
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
|
||||
@@ -43,6 +55,7 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
getAssets: mockGetAssets,
|
||||
updateModelsForNodeType: mockUpdateModelsForNodeType,
|
||||
invalidateModelsForCategory: mockInvalidateModelsForCategory,
|
||||
updateModelsForTag: vi.fn()
|
||||
@@ -64,9 +77,42 @@ 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
|
||||
@@ -87,54 +133,17 @@ 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(
|
||||
@@ -175,28 +184,149 @@ 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 } = setupMissingModelInteractions()
|
||||
const { isModelExpanded } = useMissingModelInteractions()
|
||||
expect(isModelExpanded('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles to expanded', () => {
|
||||
const { toggleModelExpand, isModelExpanded } =
|
||||
setupMissingModelInteractions()
|
||||
useMissingModelInteractions()
|
||||
toggleModelExpand('key1')
|
||||
expect(isModelExpanded('key1')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles back to collapsed', () => {
|
||||
const { toggleModelExpand, isModelExpanded } =
|
||||
setupMissingModelInteractions()
|
||||
useMissingModelInteractions()
|
||||
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 = {}
|
||||
@@ -217,7 +347,6 @@ 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' })
|
||||
@@ -225,7 +354,7 @@ describe('useMissingModelInteractions', () => {
|
||||
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = setupMissingModelInteractions()
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect(
|
||||
'key1',
|
||||
'old_model.safetensors',
|
||||
@@ -243,7 +372,6 @@ describe('useMissingModelInteractions', () => {
|
||||
new Set(['10', '20'])
|
||||
)
|
||||
expect(store.selectedLibraryModel['key1']).toBeUndefined()
|
||||
expect(store.importTaskIds['key1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing when no selection exists', () => {
|
||||
@@ -251,7 +379,7 @@ describe('useMissingModelInteractions', () => {
|
||||
const store = useMissingModelStore()
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = setupMissingModelInteractions()
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], null)
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
@@ -263,7 +391,7 @@ describe('useMissingModelInteractions', () => {
|
||||
store.selectedLibraryModel['key1'] = 'new.safetensors'
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = setupMissingModelInteractions()
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], null)
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
@@ -279,16 +407,169 @@ describe('useMissingModelInteractions', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'new.safetensors'
|
||||
|
||||
const { confirmLibrarySelect } = setupMissingModelInteractions()
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
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 } = setupMissingModelInteractions()
|
||||
const { getDownloadStatus } = useMissingModelInteractions()
|
||||
expect(getDownloadStatus('key1')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -300,7 +581,7 @@ describe('useMissingModelInteractions', () => {
|
||||
{ taskId: 'task-42', status: 'created' }
|
||||
])
|
||||
|
||||
const { getDownloadStatus } = setupMissingModelInteractions()
|
||||
const { getDownloadStatus } = useMissingModelInteractions()
|
||||
expect(getDownloadStatus('key1')).toEqual({
|
||||
taskId: 'task-42',
|
||||
status: 'created'
|
||||
@@ -308,20 +589,29 @@ describe('useMissingModelInteractions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUploadedModelImport', () => {
|
||||
it('tracks an async-pending result via importTaskIds and trackDownload', () => {
|
||||
describe('handleImport', () => {
|
||||
const setupImportableState = (key: string) => {
|
||||
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
|
||||
}
|
||||
|
||||
const { handleUploadedModelImport } = setupMissingModelInteractions()
|
||||
handleUploadedModelImport('key1', {
|
||||
status: 'processing',
|
||||
taskId: 'task-99',
|
||||
modelType: 'checkpoints',
|
||||
filename: 'model.safetensors'
|
||||
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 { 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',
|
||||
@@ -329,17 +619,43 @@ describe('useMissingModelInteractions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('invalidates model caches when the result is already completed', () => {
|
||||
const { handleUploadedModelImport } = setupMissingModelInteractions()
|
||||
handleUploadedModelImport('key1', {
|
||||
status: 'success',
|
||||
modelType: 'checkpoints',
|
||||
filename: 'model.safetensors'
|
||||
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' }
|
||||
})
|
||||
|
||||
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,13 +1,39 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import type { UploadModelSuccess } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
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 { 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 { MissingModelViewModel } from '@/platform/missingModel/types'
|
||||
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
|
||||
|
||||
export function getModelStateKey(
|
||||
modelName: string,
|
||||
@@ -32,12 +58,42 @@ 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)
|
||||
}
|
||||
@@ -46,6 +102,49 @@ 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,
|
||||
@@ -90,11 +189,97 @@ 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
|
||||
@@ -122,21 +307,87 @@ export function useMissingModelInteractions() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
store.selectedLibraryModel[key] = result.filename
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toggleModelExpand,
|
||||
isModelExpanded,
|
||||
getComboOptions,
|
||||
handleComboSelect,
|
||||
isSelectionConfirmable,
|
||||
cancelLibrarySelect,
|
||||
confirmLibrarySelect,
|
||||
handleUrlInput,
|
||||
getTypeMismatch,
|
||||
getDownloadStatus,
|
||||
handleUploadedModelImport
|
||||
handleImport
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('missingModelStore', () => {
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
store.modelExpandState['test-key'] = true
|
||||
store.urlInputs['test-key'] = 'https://example.com'
|
||||
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.modelExpandState).toEqual({})
|
||||
expect(store.urlInputs).toEqual({})
|
||||
expect(store.selectedLibraryModel).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -515,19 +515,17 @@ describe('missingModelStore', () => {
|
||||
makeModelCandidate('shared.safetensors', { nodeId: '65:80:5' }),
|
||||
makeModelCandidate('only-interior.safetensors', { nodeId: '65:70:64' })
|
||||
])
|
||||
store.selectedLibraryModel['shared.safetensors'] = 'shared-replacement'
|
||||
store.selectedLibraryModel['only-interior.safetensors'] =
|
||||
'interior-replacement'
|
||||
store.urlInputs['shared.safetensors'] = 'https://example.com/shared'
|
||||
store.urlInputs['only-interior.safetensors'] =
|
||||
'https://example.com/interior'
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
// 'only-interior' fully removed → interaction state cleared.
|
||||
// 'shared' still referenced by 65:80:5 → interaction state preserved.
|
||||
expect(
|
||||
store.selectedLibraryModel['only-interior.safetensors']
|
||||
).toBeUndefined()
|
||||
expect(store.selectedLibraryModel['shared.safetensors']).toBe(
|
||||
'shared-replacement'
|
||||
expect(store.urlInputs['only-interior.safetensors']).toBeUndefined()
|
||||
expect(store.urlInputs['shared.safetensors']).toBe(
|
||||
'https://example.com/shared'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -76,16 +77,26 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Persists across component re-mounts so that download progress
|
||||
// survives tab switches within the right-side panel.
|
||||
// Persists across component re-mounts so that download progress,
|
||||
// URL inputs, etc. survive 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()
|
||||
@@ -123,7 +134,13 @@ 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) {
|
||||
@@ -205,6 +222,31 @@ 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
|
||||
}
|
||||
@@ -217,9 +259,16 @@ 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 = {}
|
||||
}
|
||||
@@ -274,10 +323,19 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
modelExpandState,
|
||||
selectedLibraryModel,
|
||||
importTaskIds,
|
||||
importCategoryMismatch,
|
||||
urlInputs,
|
||||
urlMetadata,
|
||||
urlFetching,
|
||||
urlErrors,
|
||||
urlImporting,
|
||||
folderPaths,
|
||||
fileSizes,
|
||||
|
||||
setFolderPaths,
|
||||
setFileSize
|
||||
setFileSize,
|
||||
|
||||
setDebounceTimer,
|
||||
clearDebounceTimer
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,7 +49,6 @@ onUnmounted(() => {
|
||||
:can-export="viewer.canExport"
|
||||
:material-modes="viewer.materialModes"
|
||||
:has-skeleton="viewer.hasSkeleton"
|
||||
:source-format="viewer.sourceFormat"
|
||||
@update-background-image="viewer.handleBackgroundImageUpdate"
|
||||
@export-model="viewer.exportModel"
|
||||
/>
|
||||
|
||||
@@ -1170,7 +1170,7 @@ export class ComfyApp {
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
if (skipAssetScans) {
|
||||
// Only reset candidates; preserve UI state (fileSizes, etc.)
|
||||
// Only reset candidates; preserve UI state (fileSizes, urlInputs, 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.
|
||||
|
||||
@@ -37,7 +37,6 @@ type UseLoad3dViewerFn = (node?: LGraphNode) => {
|
||||
isStandaloneMode: { value: boolean }
|
||||
isSplatModel: { value: boolean }
|
||||
isPlyModel: { value: boolean }
|
||||
sourceFormat: { value: string | null }
|
||||
animations: { value: AnimationItem[] }
|
||||
playing: { value: boolean }
|
||||
selectedSpeed: { value: number }
|
||||
|
||||
Reference in New Issue
Block a user