diff --git a/browser_tests/assets/missing/cloud_missing_model_import_sections.json b/browser_tests/assets/missing/cloud_missing_model_import_sections.json new file mode 100644 index 0000000000..9273c4d438 --- /dev/null +++ b/browser_tests/assets/missing/cloud_missing_model_import_sections.json @@ -0,0 +1,60 @@ +{ + "last_node_id": 2, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "CheckpointLoaderSimple", + "pos": [100, 100], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { "name": "MODEL", "type": "MODEL", "links": null }, + { "name": "CLIP", "type": "CLIP", "links": null }, + { "name": "VAE", "type": "VAE", "links": null } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": ["cloud_importable_model.safetensors"] + }, + { + "id": 2, + "type": "LoadImage", + "pos": [560, 100], + "size": [400, 314], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { "name": "IMAGE", "type": "IMAGE", "links": null }, + { "name": "MASK", "type": "MASK", "links": null } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": ["cloud_unknown_model.safetensors", "image"] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + } + }, + "models": [ + { + "name": "cloud_importable_model.safetensors", + "url": "http://localhost:8188/api/devtools/fake_model.safetensors", + "directory": "checkpoints" + } + ], + "version": 0.4 +} diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 08e7d76195..850b233ba5 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -60,14 +60,16 @@ export const TestIds = { missingNodePacksGroup: 'error-group-missing-node', missingModelsGroup: 'error-group-missing-model', missingModelExpand: 'missing-model-expand', + missingModelImport: 'missing-model-import', + missingModelImportableRows: 'missing-model-importable-rows', missingModelLocate: 'missing-model-locate', - missingModelCopyName: 'missing-model-copy-name', - missingModelCopyUrl: 'missing-model-copy-url', + missingModelReferenceCount: 'missing-model-reference-count', + missingModelUnsupportedSection: + 'missing-model-import-not-supported-section', missingModelDownload: 'missing-model-download', missingModelActions: 'missing-model-actions', missingModelDownloadAll: 'missing-model-download-all', - missingModelRefresh: 'missing-model-refresh', - missingModelImportUnsupported: 'missing-model-import-unsupported', + missingModelRefresh: 'missing-model-header-refresh', missingMediaGroup: 'error-group-missing-media', swapNodesGroup: 'error-group-swap-nodes', swapNodeGroupCount: 'swap-node-group-count', diff --git a/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts index 922d7d319b..14dbea5f94 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts @@ -1,16 +1,29 @@ import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' -import type { Asset } from '@comfyorg/ingest-types' +import type { + Asset, + AssetCreated, + ListAssetsResponse +} from '@comfyorg/ingest-types' import { countAssetRequestsByTag, createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture' +import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper' import { TestIds } from '@e2e/fixtures/selectors' import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper' +import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema' + const WORKFLOW = 'missing/nested_subgraph_installed_model' +const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections' const OUTER_SUBGRAPH_NODE_ID = '205' const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors' +const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors' +const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors' +const CLOUD_IMPORTED_CANONICAL_MODEL_NAME = + 'models/checkpoints/cloud_importable_model.safetensors' const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = { id: 'test-lotus-depth-d-v1-1', @@ -27,13 +40,62 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = { } } +const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = { + id: 'test-existing-cloud-importable-model', + name: 'asset-record-display-name.safetensors', + hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204', + size: 2_048, + mime_type: 'application/octet-stream', + tags: ['models', 'checkpoints'], + created_at: '2026-05-05T00:00:00Z', + updated_at: '2026-05-05T00:00:00Z', + last_access_time: '2026-05-05T00:00:00Z', + user_metadata: { + filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME + } +} + const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL]) +function getRequestedIncludeTags(requestUrl: string): string[] { + return ( + new URL(requestUrl).searchParams + .get('include_tags') + ?.split(',') + .map((tag) => tag.trim()) + .filter(Boolean) ?? [] + ) +} + +function filterAssetsByRequest( + assets: ReadonlyArray, + 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 { + await page.evaluate(() => { + const api = window.app!.api + api.serverFeatureFlags.value = { + ...api.serverFeatureFlags.value, + model_upload_button_enabled: true, + private_models_enabled: true + } + }) +} + test.describe( 'Errors tab - Cloud missing models', { tag: ['@cloud', '@vue-nodes'] }, () => { test.beforeEach(async ({ comfyPage }) => { + await enableMissingModelImportFeatures(comfyPage.page) await comfyPage.settings.setSetting( 'Comfy.RightSidePanel.ShowErrorsTab', true @@ -88,5 +150,216 @@ test.describe( await expect(errorsTab).toBeHidden() }) + + test('separates importable cloud models from unsupported rows', async ({ + comfyPage + }) => { + await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW) + + const missingModelsGroup = comfyPage.page.getByTestId( + TestIds.dialogs.missingModelsGroup + ) + const importableRows = missingModelsGroup.getByTestId( + TestIds.dialogs.missingModelImportableRows + ) + const unsupportedSection = missingModelsGroup.getByTestId( + TestIds.dialogs.missingModelUnsupportedSection + ) + + await expect( + importableRows.getByRole('button', { + name: CLOUD_IMPORTABLE_MODEL_NAME, + exact: true + }) + ).toBeVisible() + await expect( + importableRows.getByTestId(TestIds.dialogs.missingModelImport) + ).toBeVisible() + + await expect(unsupportedSection).toBeVisible() + await expect( + unsupportedSection.getByText('Import Not Supported') + ).toBeVisible() + await expect( + unsupportedSection.getByText( + /Nodes that reference the models below do not support imported models/ + ) + ).toBeVisible() + await expect( + unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME) + ).toBeVisible() + await expect( + unsupportedSection.getByText('Unknown', { exact: true }) + ).toBeVisible() + await expect( + unsupportedSection.getByRole('button', { + name: 'Load Image', + exact: true + }) + ).toBeVisible() + await expect( + unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport) + ).toHaveCount(0) + }) + + test('opens cloud import with missing-model replacement context', async ({ + comfyPage + }) => { + await comfyPage.modelLibrary.mockModelFolders([ + { name: 'checkpoints', folders: [] } + ]) + await comfyPage.page.route('**/assets/remote-metadata?**', (route) => { + const response: AssetMetadata = { + content_length: 1024, + final_url: + 'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors', + content_type: 'application/octet-stream', + filename: 'replacement.safetensors', + tags: ['loras'] + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW) + + const missingModelsGroup = comfyPage.page.getByTestId( + TestIds.dialogs.missingModelsGroup + ) + await missingModelsGroup + .getByTestId(TestIds.dialogs.missingModelImport) + .click() + + const urlInput = comfyPage.page.locator( + '[data-attr="upload-model-step1-url-input"]' + ) + await expect(urlInput).toBeVisible() + await urlInput.fill( + 'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors' + ) + await comfyPage.page + .locator('[data-attr="upload-model-step1-continue-button"]') + .click() + + const uploadDialog = comfyPage.page.getByRole('dialog', { + name: /Import a model/ + }) + await expect( + uploadDialog.getByText( + `This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:` + ) + ).toBeVisible() + await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible() + await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible() + await expect( + uploadDialog.getByText( + /Locked to (Checkpoints|checkpoints) for this missing model/ + ) + ).toBeVisible() + }) + + test('uses the synced asset filename when applying an already imported cloud model', async ({ + comfyPage + }) => { + let isImportedAssetAvailable = false + const visibleAssets = () => + isImportedAssetAvailable + ? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL] + : [LOTUS_DIFFUSION_MODEL] + + await comfyPage.modelLibrary.mockModelFolders([ + { name: 'checkpoints', folders: [] } + ]) + await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => { + const assets = filterAssetsByRequest( + visibleAssets(), + route.request().url() + ) + const response: ListAssetsResponse = { + assets, + total: assets.length, + has_more: false + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + await comfyPage.page.route('**/assets/remote-metadata?**', (route) => { + const response: AssetMetadata = { + content_length: 2048, + final_url: + 'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors', + content_type: 'application/octet-stream', + filename: CLOUD_IMPORTABLE_MODEL_NAME, + tags: ['checkpoints'] + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + await comfyPage.page.route('**/assets/download', (route) => { + isImportedAssetAvailable = true + const response: AssetCreated = { + ...EXISTING_CLOUD_IMPORTABLE_MODEL, + created_new: false + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + + await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW) + + const missingModelsGroup = comfyPage.page.getByTestId( + TestIds.dialogs.missingModelsGroup + ) + await missingModelsGroup + .getByTestId(TestIds.dialogs.missingModelImport) + .click() + + const uploadDialog = comfyPage.page.getByRole('dialog', { + name: /Import a model/ + }) + const urlInput = uploadDialog.locator( + '[data-attr="upload-model-step1-url-input"]' + ) + await urlInput.fill( + 'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors' + ) + await uploadDialog + .locator('[data-attr="upload-model-step1-continue-button"]') + .click() + await expect( + uploadDialog.getByText( + `This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:` + ) + ).toBeVisible() + + await uploadDialog + .locator('[data-attr="upload-model-step2-confirm-button"]') + .click() + + await expect + .poll(() => + comfyPage.page.evaluate(() => { + const node = window.app!.graph.getNodeById(1) + return node?.widgets?.find((widget) => widget.name === 'ckpt_name') + ?.value + }) + ) + .toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME) + }) } ) diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts index b5f5e3836b..c071201417 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test' +import type { Locator } from '@playwright/test' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' @@ -11,6 +12,18 @@ import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper' +const FAKE_MODEL_NAME = 'fake_model.safetensors' + +function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) { + return group.getByRole('button', { name: modelName, exact: true }) +} + +async function expectReferenceBadge(group: Locator, count: number) { + await expect( + group.getByTestId(TestIds.dialogs.missingModelReferenceCount) + ).toHaveText(String(count)) +} + test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting( @@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { ).toHaveText(/\S/) }) - test('Should display model name with referencing node count', async ({ - comfyPage - }) => { + test('Should display model name and metadata', async ({ comfyPage }) => { await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models') const modelsGroup = comfyPage.page.getByTestId( TestIds.dialogs.missingModelsGroup ) - await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/) + await expect(getModelLabel(modelsGroup)).toBeVisible() + await expect(modelsGroup.getByText('checkpoints')).toBeVisible() }) test('Should expand model row to show referencing nodes', async ({ @@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { 'missing/missing_models_with_nodes' ) - const locateButton = comfyPage.page.getByTestId( - TestIds.dialogs.missingModelLocate + const modelsGroup = comfyPage.page.getByTestId( + TestIds.dialogs.missingModelsGroup ) - await expect(locateButton.first()).toBeHidden() - - const expandButton = comfyPage.page.getByTestId( + const expandButton = modelsGroup.getByTestId( TestIds.dialogs.missingModelExpand ) await expect(expandButton.first()).toBeVisible() + await expectReferenceBadge(modelsGroup, 2) await expandButton.first().click() - await expect(locateButton.first()).toBeVisible() + await expect( + modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate) + ).toHaveCount(2) }) - test('Should copy model name to clipboard', async ({ comfyPage }) => { + test('Should copy model URL to clipboard', async ({ comfyPage }) => { await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models') await interceptClipboardWrite(comfyPage.page) - const copyButton = comfyPage.page.getByTestId( - TestIds.dialogs.missingModelCopyName - ) + const copyButton = comfyPage.page.getByRole('button', { + name: 'Copy URL' + }) await expect(copyButton.first()).toBeVisible() await copyButton.first().dispatchEvent('click') const copiedText = await getClipboardText(comfyPage.page) - expect(copiedText).toContain('fake_model.safetensors') + expect(copiedText).toContain('/api/devtools/') }) test.describe('OSS-specific', { tag: '@oss' }, () => { @@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { }) => { await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models') - const copyUrlButton = comfyPage.page.getByTestId( - TestIds.dialogs.missingModelCopyUrl - ) + const copyUrlButton = comfyPage.page.getByRole('button', { + name: 'Copy URL' + }) await expect(copyUrlButton.first()).toBeVisible() }) @@ -102,6 +115,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { TestIds.dialogs.missingModelDownload ) await expect(downloadButton.first()).toBeVisible() + await expect(downloadButton.first()).toHaveText('Download') }) test('Should render Download all and Refresh actions for one downloadable model', async ({ diff --git a/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts index e1fa233556..2c9ad89dee 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test' +import type { Locator } from '@playwright/test' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' @@ -8,6 +9,18 @@ import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper' +const FAKE_MODEL_NAME = 'fake_model.safetensors' + +function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) { + return group.getByRole('button', { name: modelName, exact: true }) +} + +async function expectReferenceBadge(group: Locator, count: number) { + await expect( + group.getByTestId(TestIds.dialogs.missingModelReferenceCount) + ).toHaveText(String(count)) +} + test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') @@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { 'missing/missing_models_from_node_properties' ) - const copyUrlButton = comfyPage.page.getByTestId( - TestIds.dialogs.missingModelCopyUrl - ) + const copyUrlButton = comfyPage.page.getByRole('button', { + name: 'Copy URL' + }) await expect(copyUrlButton.first()).toBeVisible() const node = await comfyPage.nodeOps.getNodeRefById('1') @@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { TestIds.dialogs.missingModelsGroup ) await expect(missingModelGroup).toBeVisible() - await expect(missingModelGroup).toContainText( - /fake_model\.safetensors\s*\(1\)/ - ) + await expect(getModelLabel(missingModelGroup)).toBeVisible() const node = await comfyPage.nodeOps.getNodeRefById('1') await node.click('title') @@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2) await comfyPage.canvas.click() - await expect(missingModelGroup).toContainText( - /fake_model\.safetensors\s*\(2\)/ - ) + await expectReferenceBadge(missingModelGroup, 2) }) test('Pasting a bypassed node does not add a new error', async ({ @@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { const missingModelGroup = comfyPage.page.getByTestId( TestIds.dialogs.missingModelsGroup ) - await expect(missingModelGroup).toContainText(/\(2\)/) + await expectReferenceBadge(missingModelGroup, 2) const node1 = await comfyPage.nodeOps.getNodeRefById('1') await node1.click('title') - await expect(missingModelGroup).toContainText(/\(1\)/) + await expect(getModelLabel(missingModelGroup)).toBeVisible() + await expect( + missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate) + ).toHaveCount(1) await comfyPage.canvas.click() - await expect(missingModelGroup).toContainText(/\(2\)/) + await expectReferenceBadge(missingModelGroup, 2) }) }) @@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { const missingModelGroup = comfyPage.page.getByTestId( TestIds.dialogs.missingModelsGroup ) - await expect(missingModelGroup).toContainText( - /fake_model\.safetensors\s*\(1\)/ - ) + await expect(getModelLabel(missingModelGroup)).toBeVisible() await comfyPage.page.evaluate((value) => { const hostNode = window.app!.graph!.getNodeById(2) @@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { const missingModelGroup = comfyPage.page.getByTestId( TestIds.dialogs.missingModelsGroup ) - await expect(missingModelGroup).toContainText( - /fake_model\.safetensors\s*\(1\)/ - ) + await expect(getModelLabel(missingModelGroup)).toBeVisible() const promotedModelCombo = comfyPage.vueNodes .getNodeByTitle('Subgraph with Promoted Missing Model') diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts index a29a54bfd0..09ad60f9a2 100644 --- a/src/components/rightSidePanel/errors/TabErrors.test.ts +++ b/src/components/rightSidePanel/errors/TabErrors.test.ts @@ -539,7 +539,7 @@ describe('TabErrors.vue', () => { ).toBeInTheDocument() }) - it('keeps missing model Refresh in the card actions when models are downloadable', () => { + it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => { const missingModel = { nodeId: '1', nodeType: 'CheckpointLoaderSimple', @@ -557,11 +557,8 @@ describe('TabErrors.vue', () => { } }) - expect( - screen.queryByTestId('missing-model-header-refresh') - ).not.toBeInTheDocument() + expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible() expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible() - expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible() }) }) diff --git a/src/components/rightSidePanel/errors/TabErrors.vue b/src/components/rightSidePanel/errors/TabErrors.vue index eafc6b77a8..2d43e339a6 100644 --- a/src/components/rightSidePanel/errors/TabErrors.vue +++ b/src/components/rightSidePanel/errors/TabErrors.vue @@ -94,9 +94,10 @@ showMissingModelHeaderRefresh " data-testid="missing-model-header-refresh" - variant="secondary" - size="sm" - class="mr-2 h-8 shrink-0 rounded-lg text-sm" + variant="muted-textonly" + size="icon" + class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground" + :aria-label="t('rightSidePanel.missingModels.refresh')" :aria-busy="missingModelStore.isRefreshingMissingModels" :aria-disabled="missingModelStore.isRefreshingMissingModels" @click.stop="handleMissingModelRefresh" @@ -112,7 +113,6 @@ aria-hidden="true" class="icon-[lucide--refresh-cw] size-4 shrink-0" /> - {{ t('rightSidePanel.missingModels.refresh') }} @@ -301,11 +300,9 @@ import { cn } from '@comfyorg/tailwind-utils' import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { useFocusNode } from '@/composables/canvas/useFocusNode' -import { useSettingStore } from '@/platform/settings/settingStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' -import { NodeBadgeMode } from '@/types/nodeSource' import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue' import CollapseToggleButton from '../layout/CollapseToggleButton.vue' @@ -319,7 +316,6 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types' import Button from '@/components/ui/button/Button.vue' import DotSpinner from '@/components/common/DotSpinner.vue' -import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils' import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall' import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes' @@ -347,7 +343,6 @@ const { t } = useI18n() const { copyToClipboard } = useCopyToClipboard() const { focusNode, enterSubgraph } = useFocusNode() const { openGitHubIssues, contactSupport } = useErrorActions() -const settingStore = useSettingStore() const rightSidePanelStore = useRightSidePanelStore() const missingModelStore = useMissingModelStore() const { shouldShowManagerButtons, shouldShowInstallButton, openManager } = @@ -371,12 +366,6 @@ function getGroupSize(group: ErrorGroup) { return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default' } -const showNodeIdBadge = computed( - () => - (settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !== - NodeBadgeMode.None -) - function isExecutionItemListGroup(group: ErrorGroup) { return ( group.type === 'execution' && @@ -463,20 +452,13 @@ const { swapNodeGroups } = useErrorGroups(searchQuery) -const missingModelDownloadableModels = computed(() => { - if (isCloud) return [] - - return getDownloadableModels(missingModelGroups.value) -}) - const showMissingModelHeaderRefresh = computed( - () => - !isCloud && - missingModelGroups.value.length > 0 && - missingModelDownloadableModels.value.length === 0 + () => !isCloud && missingModelGroups.value.length > 0 ) function handleMissingModelRefresh() { + if (missingModelStore.isRefreshingMissingModels) return + void missingModelStore.refreshMissingModels() } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 422e748804..9ce984403c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -3091,6 +3091,13 @@ "loadingModels": "Loading {type}...", "maxFileSize": "Max file size: {size}", "maxFileSizeValue": "1 GB", + "missingModelImportTypeLocked": "Locked to {type} for this missing model", + "missingModelImportTypeMismatchAlreadyImported": "This file is already imported as {actual}.", + "missingModelImportTypeMismatchNextAction": "Try importing a different {required} model that this node can use.", + "missingModelImportTypeMismatchRequired": "This node requires {required}, so this import cannot resolve the missing model.", + "missingModelImportTypeMismatchTitle": "This model cannot resolve the missing model.", + "missingModelImportUnknownType": "another model type", + "missingModelImportWillReplace": "This import will replace {model} in:", "modelAssociatedWithLink": "The model associated with the link you provided:", "modelName": "Model Name", "modelNamePlaceholder": "Enter a name for this model", @@ -3643,32 +3650,17 @@ "expand": "Expand" }, "missingModels": { - "urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)", - "or": "OR", - "useFromLibrary": "Use from Library", - "usingFromLibrary": "Using from Library", - "unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.", - "metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.", "import": "Import", "importing": "Importing...", - "imported": "Imported", - "importFailed": "Import failed", - "typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?", - "importAnyway": "Import Anyway", - "alreadyExistsInCategory": "This model already exists in \"{category}\"", - "customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.", + "customNodeDownloadDisabled": "Nodes that reference the models below do not support imported models. Open the node to choose a supported built-in model, or replace it with a standard node that supports imported models.", "importNotSupported": "Import Not Supported", "copyModelName": "Copy model name", "copyUrl": "Copy URL", - "confirmSelection": "Confirm selection", "locateNode": "Locate node on canvas", - "cancelSelection": "Cancel selection", - "clearUrl": "Clear URL", "expandNodes": "Show referencing nodes", "collapseNodes": "Hide referencing nodes", "unknownCategory": "Unknown", "missingModelsTitle": "Missing Models", - "assetLoadTimeout": "Model detection timed out. Try reloading the workflow.", "downloadAll": "Download all", "refresh": "Refresh", "refreshing": "Refreshing missing models.", diff --git a/src/platform/assets/components/UploadModelConfirmation.test.ts b/src/platform/assets/components/UploadModelConfirmation.test.ts new file mode 100644 index 0000000000..78270145d6 --- /dev/null +++ b/src/platform/assets/components/UploadModelConfirmation.test.ts @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/vue' +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } +import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard' + +import UploadModelConfirmation from './UploadModelConfirmation.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages }, + missingWarn: false, + fallbackWarn: false, + escapeParameter: true +}) + +const SingleSelectStub = { + name: 'SingleSelect', + props: { + disabled: Boolean, + modelValue: String + }, + template: + '' +} + +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() + }) +}) diff --git a/src/platform/assets/components/UploadModelConfirmation.vue b/src/platform/assets/components/UploadModelConfirmation.vue index ec5056a6df..3f0f2c362c 100644 --- a/src/platform/assets/components/UploadModelConfirmation.vue +++ b/src/platform/assets/components/UploadModelConfirmation.vue @@ -22,16 +22,50 @@ +
+ + + +
    +
  • + + {{ target.nodeLabel }} + + + - {{ target.widgetName }} + +
  • +
+
+
-
- - - - {{ $t('assetBrowser.notSureLeaveAsIs') }} - +
+
+ +