mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-29 19:17:55 +00:00
## Summary This PR intentionally narrows workflow-embedded model metadata handling so root-level `models[]` and node-level embedded model metadata can enrich existing missing-model candidates, but can no longer create new candidates by themselves. ## Why this PR exists ADR 0009, **Subgraph promoted widgets use linked inputs**, changes promoted value ownership for subgraphs. That design was implemented by [#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197), **Subgraph Link Only Promotion (ADR 0009)**. Under ADR 0009, a promoted widget is represented as a standard linked `SubgraphInput` on the host `SubgraphNode`. The host boundary owns the promoted value identity through the host node locator plus `SubgraphInput.name`. The interior source widget remains the provider of schema, type, options, tooltip, defaults, diagnostics, and migration metadata, but it is not the persistence owner of the promoted value. This PR is a preparatory cleanup discovered while working on the missing model detection follow-up required by that ADR 0009 / [#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197) behavior. The follow-up needs missing model detection to respect the new subgraph promoted-widget ownership model. While reviewing that path, we found that the existing embedded model metadata fallback in `enrichWithEmbeddedMetadata` was doing more than metadata enrichment. The important finding was that this fallback was not just attaching metadata to candidates that had already been detected from live node widgets. It could also synthesize brand-new `MissingModelCandidate` entries from workflow JSON metadata, including root-level `models[]` entries, when no live candidate existed. That behavior is inaccurate for the missing model system for two reasons. First, the normal missing model lifecycle is anchored to a real node/widget binding. A candidate found from a COMBO or asset widget has a concrete `nodeId + widgetName` reference. That reference is what lets the UI surface the error, cache it as a pending warning, and later clear or resolve it when the underlying node/widget value is fixed. A root-level `models[]` entry does not reliably provide that anchor. If metadata-only fallback creates a candidate without a real live widget reference, the resulting error can be detected but cannot reliably travel through the existing clearing path. In practice, that can become an effectively unremovable missing model warning unless the user downloads exactly the same model referenced by the stale metadata. Second, a missing model error is meant to mean that a model-selecting widget on an active node references a value that is not available. Workflow JSON metadata by itself is not the same source of truth. If a model only appears in root workflow metadata, or appears in node metadata that is not represented by an active COMBO or asset widget candidate, that is a different kind of state from the existing missing model error model. Treating that metadata as a candidate creates a second, less reliable detector that is not aligned with the scan/clear lifecycle. This is especially important before the ADR 0009 missing-model follow-up. With linked-input promoted widgets, the host promoted value is the value that matters. The interior source widget may still carry stale or default metadata, and it must not become a second source of truth for missing model errors. A detection path that can create candidates directly from workflow metadata would make it harder to reason about which value actually produced the warning. For those reasons, this PR removes metadata-only candidate synthesis and keeps embedded metadata in the role it can perform safely: metadata enrichment. If the live widget/asset scan produces a candidate, embedded metadata may fill in `directory`, `url`, `hash`, and `hashType`. If no live candidate exists, the metadata is not enough to create a missing model warning. This PR is intended to land before the child PR that updates runtime missing model detection for ADR 0009 linked-input promoted widgets. ## Changes - **What**: Restrict `enrichWithEmbeddedMetadata` to enriching existing candidates instead of creating fallback candidates from unmatched root `models[]` or embedded model metadata. - **What**: Remove the now-unused installed-model check callback and asset-support callback from `enrichWithEmbeddedMetadata`. - **What**: Remove the now-unnecessary `modelStore.loadModelFolders()` path from the missing model pipeline, since embedded metadata no longer performs installed-model fallback detection. - **What**: Remove dead source-tracking metadata (`EmbeddedModelWithSource`, source node/widget fields, and widget-name lookup) that only existed to support metadata-only synthesis. - **What**: Update missing model tests so they assert the new contract: metadata enriches live candidates, but does not create candidates without a live scan result. - **What**: Delete obsolete fixtures that only covered the removed metadata-only synthesis path. - **Breaking**: None expected. This is an intentional narrowing of an inaccurate fallback detector, not a public API change. - **Dependencies**: None. ## Review Focus Please focus on whether the candidate lifecycle now has a single source of truth: live COMBO/asset widget scanning creates candidates, while workflow metadata only enriches those candidates. The intended behavioral change is that a model present only in workflow-level metadata, with no active node widget candidate referencing it, no longer appears as a missing model. This avoids surfacing warnings that cannot be cleared through the normal `nodeId + widgetName` path. The expected retained behavior is that active widget-referenced missing models are still detected by `scanAllModelCandidates`, and metadata from root `models[]` or node `properties.models` still supplies download-related fields for those live candidates. ## Screenshots (if applicable) Not applicable. This is a detection/pipeline behavior change covered by unit tests. ## Validation - `pnpm test:unit src/platform/missingModel/missingModelScan.test.ts src/platform/missingModel/missingModelPipeline.test.ts` - `pnpm exec eslint src/platform/missingModel/missingModelScan.ts src/platform/missingModel/missingModelScan.test.ts src/platform/missingModel/missingModelPipeline.ts src/platform/missingModel/missingModelPipeline.test.ts src/platform/missingModel/types.ts` - `pnpm exec oxfmt --check src/platform/missingModel/missingModelScan.ts src/platform/missingModel/missingModelScan.test.ts src/platform/missingModel/missingModelPipeline.ts src/platform/missingModel/missingModelPipeline.test.ts src/platform/missingModel/types.ts` - `pnpm typecheck` - pre-push hook: `knip --cache`
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import type { FlattenableWorkflowGraph } from '@/platform/workflow/core/utils/workflowFlattening'
|
|
import { flattenWorkflowNodes } from '@/platform/workflow/core/utils/workflowFlattening'
|
|
import type { MissingModelCandidate, MissingModelViewModel } from './types'
|
|
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
// eslint-disable-next-line import-x/no-restricted-paths
|
|
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
|
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
|
import type {
|
|
IAssetWidget,
|
|
IBaseWidget,
|
|
IComboWidget
|
|
} from '@/lib/litegraph/src/types/widgets'
|
|
import {
|
|
collectAllNodes,
|
|
getExecutionIdByNode
|
|
} from '@/utils/graphTraversalUtil'
|
|
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
|
import { resolveComboValues } from '@/utils/litegraphUtil'
|
|
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
|
|
|
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
|
models?: ModelFile[]
|
|
}
|
|
|
|
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
|
return widget.type === 'combo'
|
|
}
|
|
|
|
/**
|
|
* Fills url/hash/directory onto a candidate from the node's embedded
|
|
* `properties.models` metadata when the names match. The full pipeline
|
|
* does this via enrichWithEmbeddedMetadata + graphData.models, but the
|
|
* realtime single-node scan (paste, un-bypass) otherwise loses these
|
|
* fields — making the Missing Model row's download/copy-url buttons
|
|
* disappear after a bypass/un-bypass cycle.
|
|
*/
|
|
function enrichCandidateFromNodeProperties(
|
|
candidate: MissingModelCandidate,
|
|
embeddedModels: readonly ModelFile[] | undefined
|
|
): MissingModelCandidate {
|
|
if (!embeddedModels?.length) return candidate
|
|
// Require directory agreement when the candidate already has one —
|
|
// a single node can reference two models with the same name under
|
|
// different directories (e.g. a LoRA present in multiple folders);
|
|
// name-only matching would stamp the wrong url/hash onto the
|
|
// candidate. Mirrors the directory check in enrichWithEmbeddedMetadata.
|
|
const match = embeddedModels.find(
|
|
(m) =>
|
|
m.name === candidate.name &&
|
|
(!candidate.directory || candidate.directory === m.directory)
|
|
)
|
|
if (!match) return candidate
|
|
return {
|
|
...candidate,
|
|
directory: candidate.directory ?? match.directory,
|
|
url: candidate.url ?? match.url,
|
|
hash: candidate.hash ?? match.hash,
|
|
hashType: candidate.hashType ?? match.hash_type
|
|
}
|
|
}
|
|
|
|
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
|
return widget.type === 'asset'
|
|
}
|
|
|
|
function isInactiveMode(mode: number | undefined): boolean {
|
|
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
|
|
}
|
|
|
|
// Full set of model file extensions used for scanning candidate widgets.
|
|
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
|
|
// which restricts which files are eligible for download.
|
|
export const MODEL_FILE_EXTENSIONS = new Set([
|
|
'.safetensors',
|
|
'.ckpt',
|
|
'.pt',
|
|
'.pth',
|
|
'.bin',
|
|
'.sft',
|
|
'.onnx',
|
|
'.gguf'
|
|
])
|
|
|
|
export function isModelFileName(name: string): boolean {
|
|
const lower = name.toLowerCase()
|
|
return Array.from(MODEL_FILE_EXTENSIONS).some((ext) => lower.endsWith(ext))
|
|
}
|
|
|
|
/**
|
|
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
|
|
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
|
|
*
|
|
* Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
|
|
* Asset-supported nodes: `isMissing` left `undefined` for async verification.
|
|
*/
|
|
export function scanAllModelCandidates(
|
|
rootGraph: LGraph,
|
|
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
|
getDirectory?: (nodeType: string) => string | undefined
|
|
): MissingModelCandidate[] {
|
|
if (!rootGraph) return []
|
|
|
|
const allNodes = collectAllNodes(rootGraph)
|
|
const candidates: MissingModelCandidate[] = []
|
|
|
|
for (const node of allNodes) {
|
|
if (!node.widgets?.length) continue
|
|
// Skip subgraph container nodes: their promoted widgets are synthetic
|
|
// views of interior widgets, which are already scanned via recursion.
|
|
if (node.isSubgraphNode?.()) continue
|
|
if (isInactiveMode(node.mode)) continue
|
|
|
|
candidates.push(
|
|
...scanNodeModelCandidates(
|
|
rootGraph,
|
|
node,
|
|
isAssetSupported,
|
|
getDirectory
|
|
)
|
|
)
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
/** Scan a single node's widgets for missing model candidates (OSS immediate resolution). */
|
|
export function scanNodeModelCandidates(
|
|
rootGraph: LGraph,
|
|
node: LGraphNode,
|
|
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
|
getDirectory?: (nodeType: string) => string | undefined
|
|
): MissingModelCandidate[] {
|
|
if (!node.widgets?.length) return []
|
|
|
|
const executionId = getExecutionIdByNode(rootGraph, node)
|
|
if (!executionId) return []
|
|
|
|
const candidates: MissingModelCandidate[] = []
|
|
const embeddedModels = (node as { properties?: { models?: ModelFile[] } })
|
|
.properties?.models
|
|
for (const widget of node.widgets) {
|
|
let candidate: MissingModelCandidate | null = null
|
|
|
|
if (isAssetWidget(widget)) {
|
|
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
|
|
} else if (isComboWidget(widget)) {
|
|
candidate = scanComboWidget(
|
|
node,
|
|
widget,
|
|
executionId,
|
|
isAssetSupported,
|
|
getDirectory
|
|
)
|
|
}
|
|
|
|
if (candidate) {
|
|
candidates.push(
|
|
enrichCandidateFromNodeProperties(candidate, embeddedModels)
|
|
)
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
function scanAssetWidget(
|
|
node: { type: string },
|
|
widget: IAssetWidget,
|
|
executionId: string,
|
|
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
|
): MissingModelCandidate | null {
|
|
const value = widget.value
|
|
if (typeof value !== 'string' || !value.trim()) return null
|
|
if (!isModelFileName(value)) return null
|
|
|
|
return {
|
|
nodeId: executionId,
|
|
nodeType: node.type,
|
|
widgetName: widget.name,
|
|
isAssetSupported: true,
|
|
name: value,
|
|
directory: getDirectory?.(node.type),
|
|
isMissing: undefined
|
|
}
|
|
}
|
|
|
|
function scanComboWidget(
|
|
node: { type: string },
|
|
widget: IComboWidget,
|
|
executionId: string,
|
|
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
|
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
|
): MissingModelCandidate | null {
|
|
const value = widget.value
|
|
if (typeof value !== 'string' || !value.trim()) return null
|
|
if (!isModelFileName(value)) return null
|
|
|
|
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
|
|
const options = resolveComboValues(widget)
|
|
const inOptions = options.includes(value)
|
|
|
|
return {
|
|
nodeId: executionId,
|
|
nodeType: node.type,
|
|
widgetName: widget.name,
|
|
isAssetSupported: nodeIsAssetSupported,
|
|
name: value,
|
|
directory: getDirectory?.(node.type),
|
|
isMissing: nodeIsAssetSupported ? undefined : !inOptions
|
|
}
|
|
}
|
|
|
|
export function enrichWithEmbeddedMetadata(
|
|
candidates: readonly MissingModelCandidate[],
|
|
graphData: MissingModelWorkflowData
|
|
): MissingModelCandidate[] {
|
|
const allNodes = flattenWorkflowNodes(graphData)
|
|
const embeddedModels = collectEmbeddedModels(allNodes, graphData)
|
|
|
|
const enriched = candidates.map((c) => ({ ...c }))
|
|
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
|
|
for (const c of enriched) {
|
|
const dirKey = `${c.name}::${c.directory ?? ''}`
|
|
const dirList = candidatesByKey.get(dirKey)
|
|
if (dirList) dirList.push(c)
|
|
else candidatesByKey.set(dirKey, [c])
|
|
|
|
const nameKey = c.name
|
|
const nameList = candidatesByKey.get(nameKey)
|
|
if (nameList) nameList.push(c)
|
|
else candidatesByKey.set(nameKey, [c])
|
|
}
|
|
|
|
const deduped: ModelFile[] = []
|
|
const enrichedKeys = new Set<string>()
|
|
for (const model of embeddedModels) {
|
|
const dedupeKey = `${model.name}::${model.directory}`
|
|
if (enrichedKeys.has(dedupeKey)) continue
|
|
enrichedKeys.add(dedupeKey)
|
|
deduped.push(model)
|
|
}
|
|
|
|
for (const model of deduped) {
|
|
const dirKey = `${model.name}::${model.directory}`
|
|
const exact = candidatesByKey.get(dirKey)
|
|
const fallback = candidatesByKey.get(model.name)
|
|
const existing = exact?.length ? exact : fallback
|
|
if (!existing) continue
|
|
for (const c of existing) {
|
|
if (c.directory && c.directory !== model.directory) continue
|
|
c.directory ??= model.directory
|
|
c.url ??= model.url
|
|
c.hash ??= model.hash
|
|
c.hashType ??= model.hash_type
|
|
}
|
|
}
|
|
|
|
return enriched
|
|
}
|
|
|
|
function collectEmbeddedModels(
|
|
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
|
graphData: MissingModelWorkflowData
|
|
): ModelFile[] {
|
|
const result: ModelFile[] = []
|
|
const nodesById = new Map(allNodes.map((node) => [String(node.id), node]))
|
|
|
|
for (const node of allNodes) {
|
|
if (!isNodeAndAncestorsActive(node, nodesById)) continue
|
|
|
|
const selected = getSelectedModelsMetadata(node)
|
|
if (!selected?.length) continue
|
|
|
|
result.push(...selected)
|
|
}
|
|
|
|
if (graphData.models?.length) result.push(...graphData.models)
|
|
|
|
return result
|
|
}
|
|
|
|
function isNodeAndAncestorsActive(
|
|
node: ReturnType<typeof flattenWorkflowNodes>[number],
|
|
nodesById: ReadonlyMap<
|
|
string,
|
|
ReturnType<typeof flattenWorkflowNodes>[number]
|
|
>
|
|
): boolean {
|
|
if (isInactiveMode(node.mode)) return false
|
|
|
|
for (const ancestorId of getParentExecutionIds(String(node.id))) {
|
|
const ancestor = nodesById.get(ancestorId)
|
|
if (isInactiveMode(ancestor?.mode)) return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
interface AssetVerifier {
|
|
updateModelsForNodeType: (nodeType: string) => Promise<void>
|
|
getAssets: (nodeType: string) => AssetItem[] | undefined
|
|
}
|
|
|
|
export async function verifyAssetSupportedCandidates(
|
|
candidates: MissingModelCandidate[],
|
|
signal?: AbortSignal,
|
|
assetsStore?: AssetVerifier
|
|
): Promise<void> {
|
|
if (signal?.aborted) return
|
|
|
|
const pendingCandidates = candidates.filter(
|
|
(c) => c.isAssetSupported && c.isMissing === undefined
|
|
)
|
|
if (pendingCandidates.length === 0) return
|
|
|
|
const pendingNodeTypes = new Set(
|
|
pendingCandidates.map((candidate) => candidate.nodeType)
|
|
)
|
|
|
|
const store =
|
|
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
|
|
|
const failedNodeTypes = new Set<string>()
|
|
await Promise.allSettled(
|
|
[...pendingNodeTypes].map(async (nodeType) => {
|
|
if (signal?.aborted) return
|
|
try {
|
|
await store.updateModelsForNodeType(nodeType)
|
|
} catch (err) {
|
|
failedNodeTypes.add(nodeType)
|
|
console.warn(
|
|
`[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
|
|
err
|
|
)
|
|
}
|
|
})
|
|
)
|
|
|
|
if (signal?.aborted) return
|
|
|
|
for (const c of candidates) {
|
|
if (!c.isAssetSupported || c.isMissing !== undefined) continue
|
|
if (failedNodeTypes.has(c.nodeType)) continue
|
|
|
|
const assets = store.getAssets(c.nodeType) ?? []
|
|
c.isMissing = !isAssetInstalled(c, assets)
|
|
}
|
|
}
|
|
|
|
function normalizePath(path: string): string {
|
|
return path.replace(/\\/g, '/')
|
|
}
|
|
|
|
function isAssetInstalled(
|
|
candidate: MissingModelCandidate,
|
|
assets: AssetItem[]
|
|
): boolean {
|
|
if (candidate.hash && candidate.hashType) {
|
|
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
|
if (assets.some((a) => a.hash === candidateHash)) return true
|
|
}
|
|
|
|
const normalizedName = normalizePath(candidate.name)
|
|
return assets.some((a) => {
|
|
const f = normalizePath(getAssetFilename(a))
|
|
return f === normalizedName || f.endsWith('/' + normalizedName)
|
|
})
|
|
}
|
|
|
|
export function groupCandidatesByName(
|
|
candidates: MissingModelCandidate[]
|
|
): MissingModelViewModel[] {
|
|
const map = new Map<string, MissingModelViewModel>()
|
|
for (const c of candidates) {
|
|
const existing = map.get(c.name)
|
|
if (existing) {
|
|
if (c.nodeId) {
|
|
existing.referencingNodes.push({
|
|
nodeId: c.nodeId,
|
|
widgetName: c.widgetName
|
|
})
|
|
}
|
|
} else {
|
|
map.set(c.name, {
|
|
name: c.name,
|
|
representative: c,
|
|
referencingNodes: c.nodeId
|
|
? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
|
: []
|
|
})
|
|
}
|
|
}
|
|
return Array.from(map.values())
|
|
}
|