mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
[feat] Surface missing models in Errors tab (Cloud) (#9743)
## 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)
This commit is contained in:
201
src/platform/missingModel/missingModelStore.ts
Normal file
201
src/platform/missingModel/missingModelStore.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
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'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Missing model error state and interaction state.
|
||||
* Separated from executionErrorStore to keep domain boundaries clean.
|
||||
* The executionErrorStore composes from this store for aggregate error flags.
|
||||
*/
|
||||
export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
|
||||
|
||||
const hasMissingModels = computed(
|
||||
() => !!missingModelCandidates.value?.length
|
||||
)
|
||||
|
||||
const missingModelCount = computed(
|
||||
() => missingModelCandidates.value?.length ?? 0
|
||||
)
|
||||
|
||||
const missingModelNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!missingModelCandidates.value) return ids
|
||||
for (const m of missingModelCandidates.value) {
|
||||
if (m.nodeId != null) ids.add(String(m.nodeId))
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const missingModelWidgetKeys = computed<Set<string>>(() => {
|
||||
const keys = new Set<string>()
|
||||
if (!missingModelCandidates.value) return keys
|
||||
for (const m of missingModelCandidates.value) {
|
||||
keys.add(`${String(m.nodeId)}::${m.widgetName}`)
|
||||
}
|
||||
return keys
|
||||
})
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing model node IDs,
|
||||
* including the missing model nodes themselves.
|
||||
*
|
||||
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingModelNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
)
|
||||
|
||||
const activeMissingModelGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingModelAncestorExecutionIds.value
|
||||
)
|
||||
})
|
||||
|
||||
// Persists across component re-mounts so that download progress,
|
||||
// URL inputs, etc. survive tab switches within the right-side panel.
|
||||
const modelExpandState = ref<Record<string, boolean>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
const importCategoryMismatch = ref<Record<string, string>>({})
|
||||
const importTaskIds = ref<Record<string, string>>({})
|
||||
const urlInputs = ref<Record<string, string>>({})
|
||||
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
|
||||
const urlFetching = ref<Record<string, boolean>>({})
|
||||
const urlErrors = ref<Record<string, string>>({})
|
||||
const urlImporting = ref<Record<string, boolean>>({})
|
||||
|
||||
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
onScopeDispose(cancelDebounceTimers)
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
|
||||
function setMissingModels(models: MissingModelCandidate[]) {
|
||||
missingModelCandidates.value = models.length ? models : null
|
||||
}
|
||||
|
||||
function removeMissingModelByNameOnNodes(
|
||||
modelName: string,
|
||||
nodeIds: Set<string>
|
||||
) {
|
||||
if (!missingModelCandidates.value) return
|
||||
missingModelCandidates.value = missingModelCandidates.value.filter(
|
||||
(m) =>
|
||||
m.name !== modelName ||
|
||||
m.nodeId == null ||
|
||||
!nodeIds.has(String(m.nodeId))
|
||||
)
|
||||
if (!missingModelCandidates.value.length)
|
||||
missingModelCandidates.value = null
|
||||
}
|
||||
|
||||
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
||||
return missingModelNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
|
||||
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
|
||||
return missingModelWidgetKeys.value.has(`${nodeId}::${widgetName}`)
|
||||
}
|
||||
|
||||
function isContainerWithMissingModel(node: LGraphNode): boolean {
|
||||
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 clearMissingModels() {
|
||||
_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 = {}
|
||||
}
|
||||
|
||||
return {
|
||||
missingModelCandidates,
|
||||
hasMissingModels,
|
||||
missingModelCount,
|
||||
missingModelNodeIds,
|
||||
activeMissingModelGraphIds,
|
||||
|
||||
setMissingModels,
|
||||
removeMissingModelByNameOnNodes,
|
||||
clearMissingModels,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingModelOnNode,
|
||||
isWidgetMissingModel,
|
||||
isContainerWithMissingModel,
|
||||
|
||||
modelExpandState,
|
||||
selectedLibraryModel,
|
||||
importTaskIds,
|
||||
importCategoryMismatch,
|
||||
urlInputs,
|
||||
urlMetadata,
|
||||
urlFetching,
|
||||
urlErrors,
|
||||
urlImporting,
|
||||
|
||||
setDebounceTimer,
|
||||
clearDebounceTimer
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user