-
-
-
-
- {{ model.name }} ({{ model.referencingNodes.length }})
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+ {{ displayModelName }}
+
+
+ {{ model.referencingNodes.length }}
+
+
+
+
+ {{ modelMetadataLabel }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('rightSidePanel.missingModels.importing') }}
+
+
+
+
+
+
+
+
-
-
-
-
- #{{ ref.nodeId }}
-
-
- {{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/platform/missingModel/components/MissingModelUrlInput.test.ts b/src/platform/missingModel/components/MissingModelUrlInput.test.ts
deleted file mode 100644
index 5f792a3dc1..0000000000
--- a/src/platform/missingModel/components/MissingModelUrlInput.test.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/vue'
-import userEvent from '@testing-library/user-event'
-import { createPinia, setActivePinia } from 'pinia'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { createI18n } from 'vue-i18n'
-
-import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
-
-const mockPrivateModelsEnabled = vi.hoisted(() => ({ value: true }))
-const mockShowUploadDialog = vi.hoisted(() => vi.fn())
-const mockHandleUrlInput = vi.hoisted(() => vi.fn())
-const mockHandleImport = vi.hoisted(() => vi.fn())
-
-vi.mock('@/composables/useFeatureFlags', () => ({
- useFeatureFlags: () => ({
- flags: {
- get privateModelsEnabled() {
- return mockPrivateModelsEnabled.value
- }
- }
- })
-}))
-
-vi.mock('@/platform/assets/composables/useModelUpload', () => ({
- useModelUpload: () => ({
- isUploadButtonEnabled: { value: true },
- showUploadDialog: mockShowUploadDialog
- })
-}))
-
-vi.mock(
- '@/platform/missingModel/composables/useMissingModelInteractions',
- () => ({
- useMissingModelInteractions: () => ({
- handleUrlInput: mockHandleUrlInput,
- handleImport: mockHandleImport
- })
- })
-)
-
-vi.mock('@/components/rightSidePanel/layout/TransitionCollapse.vue', () => ({
- default: {
- name: 'TransitionCollapse',
- template: '
'
- }
-}))
-
-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, '')
- })
- })
-})
diff --git a/src/platform/missingModel/components/MissingModelUrlInput.vue b/src/platform/missingModel/components/MissingModelUrlInput.vue
deleted file mode 100644
index d2176f33b2..0000000000
--- a/src/platform/missingModel/components/MissingModelUrlInput.vue
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- {{ urlMetadata[modelKey]?.filename }}
-
-
- {{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
-
-
-
-
-
-
- {{
- t('rightSidePanel.missingModels.typeMismatch', {
- detectedType: typeMismatch
- })
- }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('g.loading') }}
-
-
-
-
-
-
- {{ urlErrors[modelKey] }}
-
-
-
-
-
-
diff --git a/src/platform/missingModel/composables/useMissingModelInteractions.test.ts b/src/platform/missingModel/composables/useMissingModelInteractions.test.ts
index 2a614751ba..c647c75326 100644
--- a/src/platform/missingModel/composables/useMissingModelInteractions.test.ts
+++ b/src/platform/missingModel/composables/useMissingModelInteractions.test.ts
@@ -1,38 +1,26 @@
import { createPinia, setActivePinia } from 'pinia'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createApp } from 'vue'
+import type { App } from 'vue'
+import { createI18n } from 'vue-i18n'
+import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
const mockResolveNodeDisplayName = vi.fn()
-const mockValidateSourceUrl = vi.fn()
-const mockGetAssetMetadata = vi.fn()
-const mockUploadAssetAsync = vi.fn()
const mockTrackDownload = vi.fn()
const mockInvalidateModelsForCategory = vi.fn()
-const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
-const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
-const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
-vi.mock('@/i18n', () => ({
- st: vi.fn((_key: string, fallback: string) => fallback)
-}))
-
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
-vi.mock('vue-i18n', () => ({
- useI18n: () => ({
- t: (key: string) => key
- })
-}))
-
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
@@ -55,7 +43,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
- getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
updateModelsForTag: vi.fn()
@@ -77,42 +64,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
-vi.mock('@/platform/assets/services/assetService', () => ({
- assetService: {
- getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args),
- uploadAssetAsync: (...args: unknown[]) => mockUploadAssetAsync(...args)
- }
-}))
-
-vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
- getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
- getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
-}))
-
-vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
- civitaiImportSource: {
- type: 'civitai',
- name: 'Civitai',
- hostnames: ['civitai.com', 'civitai.red']
- }
-}))
-
-vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
- huggingfaceImportSource: {
- type: 'huggingface',
- name: 'Hugging Face',
- hostnames: ['huggingface.co']
- }
-}))
-
-vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
- validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
-}))
-
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
- getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
@@ -133,17 +87,54 @@ function makeCandidate(
}
describe('useMissingModelInteractions', () => {
+ const mountedApps: App
[] = []
+
+ function setupWithI18n(factory: () => T): T {
+ let result: T | undefined
+ const host = document.createElement('div')
+ const app = createApp({
+ setup() {
+ result = factory()
+ return () => null
+ }
+ })
+ app.use(
+ createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: enMessages }
+ })
+ )
+ app.mount(host)
+ mountedApps.push(app)
+
+ if (result === undefined) {
+ throw new Error('Composable setup did not run')
+ }
+ return result
+ }
+
+ function setupMissingModelInteractions(): ReturnType<
+ typeof useMissingModelInteractions
+ > {
+ return setupWithI18n(() => useMissingModelInteractions())
+ }
+
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
- mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
- mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
+ afterEach(() => {
+ for (const app of mountedApps.splice(0)) {
+ app.unmount()
+ }
+ })
+
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
@@ -184,149 +175,28 @@ describe('useMissingModelInteractions', () => {
})
})
- describe('getComboValue', () => {
- it('returns undefined when node is not found', () => {
- ;(app as { rootGraph: unknown }).rootGraph = {}
- mockGetNodeByExecutionId.mockReturnValue(null)
-
- const result = getComboValue(makeCandidate())
- expect(result).toBeUndefined()
- })
-
- it('returns undefined when widget is not found', () => {
- ;(app as { rootGraph: unknown }).rootGraph = {}
- mockGetNodeByExecutionId.mockReturnValue({
- widgets: [{ name: 'other_widget', value: 'test' }]
- })
-
- const result = getComboValue(makeCandidate())
- expect(result).toBeUndefined()
- })
-
- it('returns string value directly', () => {
- ;(app as { rootGraph: unknown }).rootGraph = {}
- mockGetNodeByExecutionId.mockReturnValue({
- widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
- })
-
- expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
- })
-
- it('returns stringified number value', () => {
- ;(app as { rootGraph: unknown }).rootGraph = {}
- mockGetNodeByExecutionId.mockReturnValue({
- widgets: [{ name: 'ckpt_name', value: 42 }]
- })
-
- expect(getComboValue(makeCandidate())).toBe('42')
- })
-
- it('returns undefined for unexpected types', () => {
- ;(app as { rootGraph: unknown }).rootGraph = {}
- mockGetNodeByExecutionId.mockReturnValue({
- widgets: [{ name: 'ckpt_name', value: { complex: true } }]
- })
-
- expect(getComboValue(makeCandidate())).toBeUndefined()
- })
-
- it('returns undefined when nodeId is null', () => {
- const result = getComboValue(makeCandidate({ nodeId: undefined }))
- expect(result).toBeUndefined()
- })
- })
-
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
- const { isModelExpanded } = useMissingModelInteractions()
+ const { isModelExpanded } = setupMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
- useMissingModelInteractions()
+ setupMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
- useMissingModelInteractions()
+ setupMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
- describe('handleComboSelect', () => {
- it('sets selectedLibraryModel in store', () => {
- const store = useMissingModelStore()
- const { handleComboSelect } = useMissingModelInteractions()
-
- handleComboSelect('key1', 'model_v2.safetensors')
- expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
- })
-
- it('does not set value when undefined', () => {
- const store = useMissingModelStore()
- const { handleComboSelect } = useMissingModelInteractions()
-
- handleComboSelect('key1', undefined)
- expect(store.selectedLibraryModel['key1']).toBeUndefined()
- })
- })
-
- describe('isSelectionConfirmable', () => {
- it('returns false when no selection exists', () => {
- const { isSelectionConfirmable } = useMissingModelInteractions()
- expect(isSelectionConfirmable('key1')).toBe(false)
- })
-
- it('returns false when download is running', () => {
- const store = useMissingModelStore()
- store.selectedLibraryModel['key1'] = 'model.safetensors'
- store.importTaskIds['key1'] = 'task-123'
- mockDownloadList.mockReturnValue([
- { taskId: 'task-123', status: 'running' }
- ])
-
- const { isSelectionConfirmable } = useMissingModelInteractions()
- expect(isSelectionConfirmable('key1')).toBe(false)
- })
-
- it('returns false when importCategoryMismatch exists', () => {
- const store = useMissingModelStore()
- store.selectedLibraryModel['key1'] = 'model.safetensors'
- store.importCategoryMismatch['key1'] = 'loras'
-
- const { isSelectionConfirmable } = useMissingModelInteractions()
- expect(isSelectionConfirmable('key1')).toBe(false)
- })
-
- it('returns true when selection is ready with no active download', () => {
- const store = useMissingModelStore()
- store.selectedLibraryModel['key1'] = 'model.safetensors'
- mockDownloadList.mockReturnValue([])
-
- const { isSelectionConfirmable } = useMissingModelInteractions()
- expect(isSelectionConfirmable('key1')).toBe(true)
- })
- })
-
- describe('cancelLibrarySelect', () => {
- it('clears selectedLibraryModel and importCategoryMismatch', () => {
- const store = useMissingModelStore()
- store.selectedLibraryModel['key1'] = 'model.safetensors'
- store.importCategoryMismatch['key1'] = 'loras'
-
- const { cancelLibrarySelect } = useMissingModelInteractions()
- cancelLibrarySelect('key1')
-
- expect(store.selectedLibraryModel['key1']).toBeUndefined()
- expect(store.importCategoryMismatch['key1']).toBeUndefined()
- })
- })
-
describe('confirmLibrarySelect', () => {
it('updates widget values on referencing nodes and removes missing model', () => {
const mockGraph = {}
@@ -347,6 +217,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
+ store.importTaskIds['key1'] = 'task-123'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
@@ -354,7 +225,7 @@ describe('useMissingModelInteractions', () => {
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
- const { confirmLibrarySelect } = useMissingModelInteractions()
+ const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
@@ -372,6 +243,7 @@ describe('useMissingModelInteractions', () => {
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
+ expect(store.importTaskIds['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
@@ -379,7 +251,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
- const { confirmLibrarySelect } = useMissingModelInteractions()
+ const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -391,7 +263,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
- const { confirmLibrarySelect } = useMissingModelInteractions()
+ const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -407,169 +279,16 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
- const { confirmLibrarySelect } = useMissingModelInteractions()
+ const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
})
})
- describe('handleUrlInput', () => {
- it('clears previous state on new input', () => {
- const store = useMissingModelStore()
- store.urlMetadata['key1'] = { name: 'old' } as never
- store.urlErrors['key1'] = 'old error'
- store.urlFetching['key1'] = true
-
- const { handleUrlInput } = useMissingModelInteractions()
- handleUrlInput('key1', 'https://civitai.com/models/123')
-
- expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
- expect(store.urlMetadata['key1']).toBeUndefined()
- expect(store.urlErrors['key1']).toBeUndefined()
- expect(store.urlFetching['key1']).toBe(false)
- })
-
- it('does not set debounce timer for empty input', () => {
- const store = useMissingModelStore()
- const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
-
- const { handleUrlInput } = useMissingModelInteractions()
- handleUrlInput('key1', ' ')
-
- expect(setTimerSpy).not.toHaveBeenCalled()
- })
-
- it('sets debounce timer for non-empty input', () => {
- const store = useMissingModelStore()
- const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
-
- const { handleUrlInput } = useMissingModelInteractions()
- handleUrlInput('key1', 'https://civitai.com/models/123')
-
- expect(setTimerSpy).toHaveBeenCalledWith(
- 'key1',
- expect.any(Function),
- 800
- )
- })
-
- it('clears previous debounce timer', () => {
- const store = useMissingModelStore()
- const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
-
- const { handleUrlInput } = useMissingModelInteractions()
- handleUrlInput('key1', 'https://civitai.com/models/123')
-
- expect(clearTimerSpy).toHaveBeenCalledWith('key1')
- })
- })
-
- describe('getTypeMismatch', () => {
- it('returns null when groupDirectory is null', () => {
- const { getTypeMismatch } = useMissingModelInteractions()
- expect(getTypeMismatch('key1', null)).toBeNull()
- })
-
- it('returns null when no metadata exists', () => {
- const { getTypeMismatch } = useMissingModelInteractions()
- expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
- })
-
- it('returns null when metadata has no tags', () => {
- const store = useMissingModelStore()
- store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
-
- const { getTypeMismatch } = useMissingModelInteractions()
- expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
- })
-
- it('returns null when detected type matches directory', () => {
- const store = useMissingModelStore()
- store.urlMetadata['key1'] = {
- name: 'model',
- tags: ['checkpoints']
- } as never
-
- const { getTypeMismatch } = useMissingModelInteractions()
- expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
- })
-
- it('returns detected type when it differs from directory', () => {
- const store = useMissingModelStore()
- store.urlMetadata['key1'] = {
- name: 'model',
- tags: ['loras']
- } as never
-
- const { getTypeMismatch } = useMissingModelInteractions()
- expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
- })
-
- it('returns null when tags contain no recognized model type', () => {
- const store = useMissingModelStore()
- store.urlMetadata['key1'] = {
- name: 'model',
- tags: ['other', 'random']
- } as never
-
- const { getTypeMismatch } = useMissingModelInteractions()
- expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
- })
- })
-
- describe('getComboOptions', () => {
- it('returns assets from assetsStore when the model is asset-supported', () => {
- mockGetAssets.mockReturnValueOnce([
- { name: 'modelA.safetensors' },
- { name: 'modelB.safetensors' }
- ])
-
- const { getComboOptions } = useMissingModelInteractions()
- const options = getComboOptions(makeCandidate({ isAssetSupported: true }))
-
- expect(mockGetAssets).toHaveBeenCalledWith('CheckpointLoaderSimple')
- expect(options).toEqual([
- { name: 'modelA.safetensors', value: 'modelA.safetensors' },
- { name: 'modelB.safetensors', value: 'modelB.safetensors' }
- ])
- })
-
- it('returns widget options when the model is not asset-supported', () => {
- ;(app as { rootGraph: unknown }).rootGraph = {}
- mockGetNodeByExecutionId.mockReturnValue({
- widgets: [
- {
- name: 'ckpt_name',
- value: '',
- options: { values: ['v1.safetensors', 'v2.safetensors'] }
- }
- ]
- })
-
- const { getComboOptions } = useMissingModelInteractions()
- const options = getComboOptions(makeCandidate())
-
- expect(options).toEqual([
- { name: 'v1.safetensors', value: 'v1.safetensors' },
- { name: 'v2.safetensors', value: 'v2.safetensors' }
- ])
- })
-
- it('returns an empty array when the widget has no options.values', () => {
- ;(app as { rootGraph: unknown }).rootGraph = {}
- mockGetNodeByExecutionId.mockReturnValue({
- widgets: [{ name: 'ckpt_name', value: '' }]
- })
-
- const { getComboOptions } = useMissingModelInteractions()
- expect(getComboOptions(makeCandidate())).toEqual([])
- })
- })
-
describe('getDownloadStatus', () => {
it('returns null when no taskId is tracked for the key', () => {
- const { getDownloadStatus } = useMissingModelInteractions()
+ const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
})
@@ -581,7 +300,7 @@ describe('useMissingModelInteractions', () => {
{ taskId: 'task-42', status: 'created' }
])
- const { getDownloadStatus } = useMissingModelInteractions()
+ const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
taskId: 'task-42',
status: 'created'
@@ -589,29 +308,20 @@ describe('useMissingModelInteractions', () => {
})
})
- describe('handleImport', () => {
- const setupImportableState = (key: string) => {
+ describe('handleUploadedModelImport', () => {
+ it('tracks an async-pending result via importTaskIds and trackDownload', () => {
const store = useMissingModelStore()
- store.urlInputs[key] = 'https://civitai.com/models/123'
- store.urlMetadata[key] = {
- filename: 'model.safetensors',
- name: 'model'
- } as never
- mockValidateSourceUrl.mockReturnValue(true)
- return store
- }
- it('tracks an async-pending result via importTaskIds and trackDownload', async () => {
- const store = setupImportableState('key1')
- mockUploadAssetAsync.mockResolvedValueOnce({
- type: 'async',
- task: { task_id: 'task-99', status: 'created' }
+ const { handleUploadedModelImport } = setupMissingModelInteractions()
+ handleUploadedModelImport('key1', {
+ status: 'processing',
+ taskId: 'task-99',
+ modelType: 'checkpoints',
+ filename: 'model.safetensors'
})
- const { handleImport } = useMissingModelInteractions()
- await handleImport('key1', 'checkpoints')
-
expect(store.importTaskIds['key1']).toBe('task-99')
+ expect(store.selectedLibraryModel['key1']).toBe('model.safetensors')
expect(mockTrackDownload).toHaveBeenCalledWith(
'task-99',
'checkpoints',
@@ -619,43 +329,17 @@ describe('useMissingModelInteractions', () => {
)
})
- it('invalidates model caches when the async result is already completed', async () => {
- setupImportableState('key1')
- mockUploadAssetAsync.mockResolvedValueOnce({
- type: 'async',
- task: { task_id: 'task-100', status: 'completed' }
+ it('invalidates model caches when the result is already completed', () => {
+ const { handleUploadedModelImport } = setupMissingModelInteractions()
+ handleUploadedModelImport('key1', {
+ status: 'success',
+ modelType: 'checkpoints',
+ filename: 'model.safetensors'
})
- const { handleImport } = useMissingModelInteractions()
- await handleImport('key1', 'checkpoints')
-
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
-
- it('records importCategoryMismatch when sync result tags differ from groupDirectory', async () => {
- const store = setupImportableState('key1')
- mockUploadAssetAsync.mockResolvedValueOnce({
- type: 'sync',
- asset: { tags: ['models', 'loras'] }
- })
-
- const { handleImport } = useMissingModelInteractions()
- await handleImport('key1', 'checkpoints')
-
- expect(store.importCategoryMismatch['key1']).toBe('loras')
- })
-
- it('writes the error message to urlErrors when the upload rejects', async () => {
- const store = setupImportableState('key1')
- mockUploadAssetAsync.mockRejectedValueOnce(new Error('Upload boom'))
-
- const { handleImport } = useMissingModelInteractions()
- await handleImport('key1', 'checkpoints')
-
- expect(store.urlErrors['key1']).toBe('Upload boom')
- expect(store.urlImporting['key1']).toBe(false)
- })
})
})
diff --git a/src/platform/missingModel/composables/useMissingModelInteractions.ts b/src/platform/missingModel/composables/useMissingModelInteractions.ts
index 237c68aecd..2edc274e2a 100644
--- a/src/platform/missingModel/composables/useMissingModelInteractions.ts
+++ b/src/platform/missingModel/composables/useMissingModelInteractions.ts
@@ -1,39 +1,13 @@
-import { useI18n } from 'vue-i18n'
-
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
-import { assetService } from '@/platform/assets/services/assetService'
-import {
- getAssetDisplayName,
- getAssetFilename
-} from '@/platform/assets/utils/assetMetadataUtils'
-import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
-import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
-import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
+import type { UploadModelSuccess } from '@/platform/assets/composables/useUploadModelWizard'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
-import type {
- MissingModelCandidate,
- MissingModelViewModel
-} from '@/platform/missingModel/types'
-import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
-import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
-
-const importSources = [civitaiImportSource, huggingfaceImportSource]
-
-const MODEL_TYPE_TAGS = [
- 'checkpoints',
- 'loras',
- 'vae',
- 'text_encoders',
- 'diffusion_models'
-] as const
-
-const URL_DEBOUNCE_MS = 800
+import type { MissingModelViewModel } from '@/platform/missingModel/types'
export function getModelStateKey(
modelName: string,
@@ -58,42 +32,12 @@ export function getNodeDisplayLabel(
})
}
-function getModelComboWidget(
- model: MissingModelCandidate
-): { node: LGraphNode; widget: IBaseWidget } | null {
- if (model.nodeId == null) return null
-
- const graph = app.rootGraph
- if (!graph) return null
- const node = getNodeByExecutionId(graph, String(model.nodeId))
- if (!node) return null
-
- const widget = node.widgets?.find((w) => w.name === model.widgetName)
- if (!widget) return null
-
- return { node, widget }
-}
-
-export function getComboValue(
- model: MissingModelCandidate
-): string | undefined {
- const result = getModelComboWidget(model)
- if (!result) return undefined
- const val = result.widget.value
- if (typeof val === 'string') return val
- if (typeof val === 'number') return String(val)
- return undefined
-}
-
export function useMissingModelInteractions() {
- const { t } = useI18n()
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
- const _requestTokens: Record = {}
-
function toggleModelExpand(key: string) {
store.modelExpandState[key] = !isModelExpanded(key)
}
@@ -102,49 +46,6 @@ export function useMissingModelInteractions() {
return store.modelExpandState[key] ?? false
}
- function getComboOptions(
- model: MissingModelCandidate
- ): { name: string; value: string }[] {
- if (model.isAssetSupported && model.nodeType) {
- const assets = assetsStore.getAssets(model.nodeType) ?? []
- return assets.map((asset) => ({
- name: getAssetDisplayName(asset),
- value: getAssetFilename(asset)
- }))
- }
-
- const result = getModelComboWidget(model)
- if (!result) return []
- const values = result.widget.options?.values
- if (!Array.isArray(values)) return []
- return values.map((v) => ({ name: String(v), value: String(v) }))
- }
-
- function handleComboSelect(key: string, value: string | undefined) {
- if (value) {
- store.selectedLibraryModel[key] = value
- }
- }
-
- function isSelectionConfirmable(key: string): boolean {
- if (!store.selectedLibraryModel[key]) return false
- if (store.importCategoryMismatch[key]) return false
-
- const status = getDownloadStatus(key)
- if (
- status &&
- (status.status === 'running' || status.status === 'created')
- ) {
- return false
- }
- return true
- }
-
- function cancelLibrarySelect(key: string) {
- delete store.selectedLibraryModel[key]
- delete store.importCategoryMismatch[key]
- }
-
/** Apply selected model to referencing nodes, removing only that model from the error list. */
function confirmLibrarySelect(
key: string,
@@ -189,97 +90,11 @@ export function useMissingModelInteractions() {
}
delete store.selectedLibraryModel[key]
+ delete store.importTaskIds[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
- function handleUrlInput(key: string, value: string) {
- store.urlInputs[key] = value
-
- delete store.urlMetadata[key]
- delete store.urlErrors[key]
- delete store.importCategoryMismatch[key]
- store.urlFetching[key] = false
-
- store.clearDebounceTimer(key)
-
- const trimmed = value.trim()
- if (!trimmed) return
-
- store.setDebounceTimer(
- key,
- () => {
- void fetchUrlMetadata(key, trimmed)
- },
- URL_DEBOUNCE_MS
- )
- }
-
- async function fetchUrlMetadata(key: string, url: string) {
- const source = importSources.find((s) => validateSourceUrl(url, s))
- if (!source) {
- store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
- return
- }
-
- const token = Symbol()
- _requestTokens[key] = token
-
- store.urlFetching[key] = true
- delete store.urlErrors[key]
-
- try {
- const metadata = await assetService.getAssetMetadata(url)
-
- if (_requestTokens[key] !== token) return
-
- if (metadata.filename) {
- try {
- const decoded = decodeURIComponent(metadata.filename)
- const basename = decoded.split(/[/\\]/).pop() ?? decoded
- if (!basename.includes('..')) {
- metadata.filename = basename
- }
- } catch {
- /* keep original */
- }
- }
-
- store.urlMetadata[key] = metadata
- } catch (error) {
- if (_requestTokens[key] !== token) return
-
- store.urlErrors[key] =
- error instanceof Error
- ? error.message
- : t('rightSidePanel.missingModels.metadataFetchFailed')
- } finally {
- if (_requestTokens[key] === token) {
- store.urlFetching[key] = false
- }
- }
- }
-
- function getTypeMismatch(
- key: string,
- groupDirectory: string | null
- ): string | null {
- if (!groupDirectory) return null
-
- const metadata = store.urlMetadata[key]
- if (!metadata?.tags?.length) return null
-
- const detectedType = metadata.tags.find((tag) =>
- MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
- )
- if (!detectedType) return null
-
- if (detectedType !== groupDirectory) {
- return detectedType
- }
- return null
- }
-
function getDownloadStatus(key: string) {
const taskId = store.importTaskIds[key]
if (!taskId) return null
@@ -307,87 +122,21 @@ export function useMissingModelInteractions() {
}
}
- function handleSyncResult(
- key: string,
- tags: string[],
- modelType: string | undefined
- ) {
- const existingCategory = tags.find((tag) =>
- MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
- )
- if (existingCategory && modelType && existingCategory !== modelType) {
- store.importCategoryMismatch[key] = existingCategory
+ function handleUploadedModelImport(key: string, result: UploadModelSuccess) {
+ if (result.taskId) {
+ handleAsyncPending(key, result.taskId, result.modelType, result.filename)
+ } else if (result.status === 'success') {
+ handleAsyncCompleted(result.modelType)
}
- }
- async function handleImport(key: string, groupDirectory: string | null) {
- const metadata = store.urlMetadata[key]
- if (!metadata) return
-
- const url = store.urlInputs[key]?.trim()
- if (!url) return
-
- const source = importSources.find((s) => validateSourceUrl(url, s))
- if (!source) return
-
- const token = Symbol()
- _requestTokens[key] = token
-
- store.urlImporting[key] = true
- delete store.urlErrors[key]
- delete store.importCategoryMismatch[key]
-
- try {
- const modelType = groupDirectory || undefined
- const tags = modelType ? ['models', modelType] : ['models']
- const filename = metadata.filename || metadata.name || 'model'
-
- const result = await assetService.uploadAssetAsync({
- source_url: url,
- tags,
- user_metadata: {
- source: source.type,
- source_url: url,
- model_type: modelType
- }
- })
-
- if (_requestTokens[key] !== token) return
-
- if (result.type === 'async' && result.task.status !== 'completed') {
- handleAsyncPending(key, result.task.task_id, modelType, filename)
- } else if (result.type === 'async') {
- handleAsyncCompleted(modelType)
- } else if (result.type === 'sync') {
- handleSyncResult(key, result.asset.tags ?? [], modelType)
- }
-
- store.selectedLibraryModel[key] = filename
- } catch (error) {
- if (_requestTokens[key] !== token) return
-
- store.urlErrors[key] =
- error instanceof Error
- ? error.message
- : t('rightSidePanel.missingModels.importFailed')
- } finally {
- if (_requestTokens[key] === token) {
- store.urlImporting[key] = false
- }
- }
+ store.selectedLibraryModel[key] = result.filename
}
return {
toggleModelExpand,
isModelExpanded,
- getComboOptions,
- handleComboSelect,
- isSelectionConfirmable,
- cancelLibrarySelect,
confirmLibrarySelect,
- handleUrlInput,
- getTypeMismatch,
getDownloadStatus,
- handleImport
+ handleUploadedModelImport
}
}
diff --git a/src/platform/missingModel/missingModelStore.test.ts b/src/platform/missingModel/missingModelStore.test.ts
index 86bb4acf6b..8811b5d839 100644
--- a/src/platform/missingModel/missingModelStore.test.ts
+++ b/src/platform/missingModel/missingModelStore.test.ts
@@ -214,7 +214,7 @@ describe('missingModelStore', () => {
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
- store.urlInputs['test-key'] = 'https://example.com'
+ store.modelExpandState['test-key'] = true
store.selectedLibraryModel['test-key'] = 'some-model'
expect(store.missingModelCandidates).not.toBeNull()
@@ -222,7 +222,7 @@ describe('missingModelStore', () => {
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
- expect(store.urlInputs).toEqual({})
+ expect(store.modelExpandState).toEqual({})
expect(store.selectedLibraryModel).toEqual({})
})
})
@@ -515,17 +515,19 @@ describe('missingModelStore', () => {
makeModelCandidate('shared.safetensors', { nodeId: '65:80:5' }),
makeModelCandidate('only-interior.safetensors', { nodeId: '65:70:64' })
])
- store.urlInputs['shared.safetensors'] = 'https://example.com/shared'
- store.urlInputs['only-interior.safetensors'] =
- 'https://example.com/interior'
+ store.selectedLibraryModel['shared.safetensors'] = 'shared-replacement'
+ store.selectedLibraryModel['only-interior.safetensors'] =
+ 'interior-replacement'
store.removeMissingModelsByPrefix('65:70:')
// 'only-interior' fully removed → interaction state cleared.
// 'shared' still referenced by 65:80:5 → interaction state preserved.
- expect(store.urlInputs['only-interior.safetensors']).toBeUndefined()
- expect(store.urlInputs['shared.safetensors']).toBe(
- 'https://example.com/shared'
+ expect(
+ store.selectedLibraryModel['only-interior.safetensors']
+ ).toBeUndefined()
+ expect(store.selectedLibraryModel['shared.safetensors']).toBe(
+ 'shared-replacement'
)
})
})
diff --git a/src/platform/missingModel/missingModelStore.ts b/src/platform/missingModel/missingModelStore.ts
index 3f37deb835..aa1d0c14c7 100644
--- a/src/platform/missingModel/missingModelStore.ts
+++ b/src/platform/missingModel/missingModelStore.ts
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
-import { computed, onScopeDispose, ref } from 'vue'
+import { computed, ref } from 'vue'
import { t } from '@/i18n'
// eslint-disable-next-line import-x/no-restricted-paths
@@ -7,7 +7,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
-import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
@@ -77,26 +76,16 @@ export const useMissingModelStore = defineStore('missingModel', () => {
)
})
- // Persists across component re-mounts so that download progress,
- // URL inputs, etc. survive tab switches within the right-side panel.
+ // Persists across component re-mounts so that download progress
+ // survives tab switches within the right-side panel.
const modelExpandState = ref>({})
const selectedLibraryModel = ref>({})
- const importCategoryMismatch = ref>({})
const importTaskIds = ref>({})
- const urlInputs = ref>({})
- const urlMetadata = ref>({})
- const urlFetching = ref>({})
- const urlErrors = ref>({})
- const urlImporting = ref>({})
const folderPaths = ref>({})
const fileSizes = ref>({})
- const _urlDebounceTimers: Record> = {}
-
let _verificationAbortController: AbortController | null = null
- onScopeDispose(cancelDebounceTimers)
-
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
@@ -134,13 +123,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
function clearInteractionStateForName(name: string) {
delete modelExpandState.value[name]
delete selectedLibraryModel.value[name]
- delete importCategoryMismatch.value[name]
delete importTaskIds.value[name]
- delete urlInputs.value[name]
- delete urlMetadata.value[name]
- delete urlFetching.value[name]
- delete urlErrors.value[name]
- delete urlImporting.value[name]
}
function removeMissingModelsByNodeId(nodeId: string) {
@@ -222,31 +205,6 @@ export const useMissingModelStore = defineStore('missingModel', () => {
return activeMissingModelGraphIds.value.has(String(node.id))
}
- function cancelDebounceTimers() {
- for (const key of Object.keys(_urlDebounceTimers)) {
- clearTimeout(_urlDebounceTimers[key])
- delete _urlDebounceTimers[key]
- }
- }
-
- function setDebounceTimer(
- key: string,
- callback: () => void,
- delayMs: number
- ) {
- if (_urlDebounceTimers[key]) {
- clearTimeout(_urlDebounceTimers[key])
- }
- _urlDebounceTimers[key] = setTimeout(callback, delayMs)
- }
-
- function clearDebounceTimer(key: string) {
- if (_urlDebounceTimers[key]) {
- clearTimeout(_urlDebounceTimers[key])
- delete _urlDebounceTimers[key]
- }
- }
-
function setFolderPaths(paths: Record) {
folderPaths.value = paths
}
@@ -259,16 +217,9 @@ export const useMissingModelStore = defineStore('missingModel', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingModelCandidates.value = null
- cancelDebounceTimers()
modelExpandState.value = {}
selectedLibraryModel.value = {}
- importCategoryMismatch.value = {}
importTaskIds.value = {}
- urlInputs.value = {}
- urlMetadata.value = {}
- urlFetching.value = {}
- urlErrors.value = {}
- urlImporting.value = {}
folderPaths.value = {}
fileSizes.value = {}
}
@@ -323,19 +274,10 @@ export const useMissingModelStore = defineStore('missingModel', () => {
modelExpandState,
selectedLibraryModel,
importTaskIds,
- importCategoryMismatch,
- urlInputs,
- urlMetadata,
- urlFetching,
- urlErrors,
- urlImporting,
folderPaths,
fileSizes,
setFolderPaths,
- setFileSize,
-
- setDebounceTimer,
- clearDebounceTimer
+ setFileSize
}
})
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index 2345b0871a..19de396d20 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -1170,7 +1170,7 @@ export class ComfyApp {
useWorkflowService().beforeLoadNewGraph()
if (skipAssetScans) {
- // Only reset candidates; preserve UI state (fileSizes, urlInputs, etc.)
+ // Only reset candidates; preserve UI state (fileSizes, etc.)
// so cached results restored by showPendingWarnings still display sizes.
// Abort any in-flight verification from the outgoing workflow so a late
// result cannot repopulate the store after we've switched workflows.