mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 00:50:01 +00:00
## Summary When a workflow is loaded with missing models, users currently have no way to identify or resolve them from within the UI. This PR adds a full missing-model detection and resolution pipeline that surfaces missing models in the Errors tab, allowing users to install or import them without leaving the editor. ## Changes ### Missing Model Detection - Scan all COMBO widgets across root graph and subgraphs for model-like filenames during workflow load - Enrich candidates with embedded workflow metadata (url, hash, directory) when available - Verify asset-supported candidates against the asset store asynchronously to confirm installation status - Propagate missing model state to `executionErrorStore` alongside existing node/prompt errors ### Errors Tab UI — Model Resolution - Group missing models by directory (e.g. `checkpoints`, `loras`, `vae`) with collapsible category cards - Each model row displays: - Model name with copy-to-clipboard button - Expandable list of referencing nodes with locate-on-canvas button - **Library selector**: Pick an alternative from the user's existing models to substitute the missing model with one click - **URL import**: Paste a Civitai or HuggingFace URL to import a model directly; debounced metadata fetch shows filename and file size before confirming; type-mismatch warnings (e.g. importing a LoRA into checkpoints directory) are surfaced with an "Import Anyway" option - **Upgrade prompt**: In cloud environment, free-tier subscribers are shown an upgrade modal when attempting URL import - Separate "Import Not Supported" section for custom-node models that cannot be auto-resolved - Status card with live download progress, completion, failure, and category-mismatch states ### Canvas Integration - Highlight nodes and widgets that reference missing models with error indicators - Propagate missing-model badges through subgraph containers so issues are visible at every graph level ### Code Cleanup - Simplify `surfacePendingWarnings` in workflowService, remove stale widget-detected model merging logic - Add `flattenWorkflowNodes` utility to workflowSchema for traversing nested subgraph structures - Extract `MissingModelUrlInput`, `MissingModelLibrarySelect`, `MissingModelStatusCard` as focused single-responsibility components ## Testing - Unit tests for scan pipeline (`missingModelScan.test.ts`): enrichment, skip-installed, subgraph flattening - Unit tests for store (`missingModelStore.test.ts`): state management, removal helpers - Unit tests for interactions (`useMissingModelInteractions.test.ts`): combo select, URL input, import flow, library confirm - Component tests for `MissingModelCard` and error grouping (`useErrorGroups.test.ts`) - Updated `workflowService.test.ts` and `workflowSchema.test.ts` for new logic ## Review Focus - Missing model scan + enrichment pipeline in `missingModelScan.ts` - Interaction composable `useMissingModelInteractions.ts` — URL metadata fetch, library install, upload fallback - Store integration and canvas-level error propagation ## Screenshots https://github.com/user-attachments/assets/339a6d5b-93a3-43cd-98dd-0fb00681b66f ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9743-feat-Surface-missing-models-in-Errors-tab-Cloud-3206d73d365081678326d3a16c2165d8) by [Unito](https://www.unito.io)
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
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<string, symbol> = {}
|
|
|
|
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
|
|
}
|
|
}
|