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 { 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 export function getModelStateKey( modelName: string, directory: string | null, isAssetSupported: boolean ): string { const prefix = isAssetSupported ? 'supported' : 'unsupported' return `${prefix}::${directory ?? ''}::${modelName}` } export function getNodeDisplayLabel( nodeId: string | number, fallback: string ): string { const graph = app.rootGraph if (!graph) return fallback const node = getNodeByExecutionId(graph, String(nodeId)) return resolveNodeDisplayName(node, { emptyLabel: fallback, untitledLabel: fallback, st }) } 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) } function isModelExpanded(key: string): boolean { 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, modelName: string, referencingNodes: MissingModelViewModel['referencingNodes'], directory: string | null ) { const value = store.selectedLibraryModel[key] if (!value) return const graph = app.rootGraph if (!graph) return if (directory) { const providers = modelToNodeStore.getAllNodeProviders(directory) void Promise.allSettled( providers.map((provider) => assetsStore.updateModelsForNodeType(provider.nodeDef.name) ) ).then((results) => { for (const r of results) { if (r.status === 'rejected') { console.warn( '[Missing Model] Failed to refresh model cache:', r.reason ) } } }) } for (const ref of referencingNodes) { const node = getNodeByExecutionId(graph, String(ref.nodeId)) if (node) { const widget = node.widgets?.find((w) => w.name === ref.widgetName) if (widget) { widget.value = value widget.callback?.(value) } node.graph?.setDirtyCanvas(true, true) } } delete store.selectedLibraryModel[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 return ( assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null ) } function handleAsyncPending( key: string, taskId: string, modelType: string | undefined, filename: string ) { store.importTaskIds[key] = taskId if (modelType) { assetDownloadStore.trackDownload(taskId, modelType, filename) } } function handleAsyncCompleted(modelType: string | undefined) { if (modelType) { assetsStore.invalidateModelsForCategory(modelType) void assetsStore.updateModelsForTag(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 } } 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, handleImport } }