mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 12:42:01 +00:00
Follow-up to #10856. Four correctness issues and their regression tests. ## Bugs fixed ### 1. ErrorOverlay model count reflected node selection `useErrorGroups` exposed `filteredMissingModelGroups` under the public name `missingModelGroups`. `ErrorOverlay.vue` read that alias to compute its model count label, so selecting a node shrank the overlay total. The overlay must always show the whole workflow's errors. Exposed both shapes explicitly: `missingModelGroups` / `missingMediaGroups` (unfiltered totals) and `filteredMissingModelGroups` / `filteredMissingMediaGroups` (selection-scoped). `TabErrors.vue` destructures the filtered variant with an alias. Before https://github.com/user-attachments/assets/eb848c5f-d092-4a4f-b86f-d22bb4408003 After https://github.com/user-attachments/assets/75e67819-c9f2-45ec-9241-74023eca6120 ### 2. Bypass → un-bypass dropped url/hash metadata Realtime `scanNodeModelCandidates` only reads widget values, so un-bypass produced a fresh candidate without the url that `enrichWithEmbeddedMetadata` had previously attached from `graphData.models`. `MissingModelRow`'s download/copy-url buttons disappeared after a bypass/un-bypass cycle. Added `enrichCandidateFromNodeProperties` that copies `url`/`hash`/`directory` from the node's own `properties.models` — which persists across mode toggles — into each scanned candidate. Applied to every call site of the per-node scan. A later fix in the same branch also enforces directory agreement to prevent a same-name / different-directory collision from stamping the wrong metadata. Before https://github.com/user-attachments/assets/39039d83-4d55-41a9-9d01-dec40843741b After https://github.com/user-attachments/assets/047a603b-fb52-4320-886d-dfeed457d833 ### 3. Initial full scan surfaced interior errors of a muted/bypassed subgraph container `scanAllModelCandidates`, `scanAllMediaCandidates`, and the JSON-based missing-node scan only check each node's own mode. Interior nodes whose parent container was bypassed passed the filter. Added `isAncestorPathActive(rootGraph, executionId)` to `graphTraversalUtil` and post-filter the three pipelines in `app.ts` after the live rootGraph is configured. The filter uses the execution-ID path (`"65:63"` → check node 65's mode) so it handles both live-scan-produced and JSON-enrichment-produced candidates. Before https://github.com/user-attachments/assets/3032d46b-81cd-420e-ab8e-f58392267602 After https://github.com/user-attachments/assets/02a01931-951d-4a48-986c-06424044fbf8 ### 4. Bypassed subgraph entry re-surfaced interior errors `useGraphNodeManager` replays `graph.onNodeAdded` for each existing interior node when the Vue node manager initializes on subgraph entry. That chain reached `scanSingleNodeErrors` via `installErrorClearingHooks`' `onNodeAdded` override. Each interior node's own mode was active, so the caller guards passed and the scan re-introduced the error that the initial pipeline had correctly suppressed. Added an ancestor-activity gate at the top of `scanSingleNodeErrors`, the single entry point shared by paste, un-bypass, subgraph entry, and subgraph container activation. A later commit also hardens this guard against detached nodes (null execution ID → skip) and applies the same ancestor check to `isCandidateStillActive` in the realtime verification callback. Before https://github.com/user-attachments/assets/fe44862d-f1d6-41ed-982d-614a7e83d441 After https://github.com/user-attachments/assets/497a76ce-3caa-479f-9024-4cd0f7bd20a4 ## Tests - 6 unit tests for `isAncestorPathActive` (root, active, immediate-bypass, deep-nested mute, unresolvable ancestor, null rootGraph) - 4 unit tests for `enrichCandidateFromNodeProperties` (enrichment, no-overwrite, name mismatch, directory mismatch) - 1 unit test for `scanSingleNodeErrors` ancestor guard (subgraph entry replaying onNodeAdded) - 2 unit tests for `useErrorGroups` dual export + ErrorOverlay contract - 4 E2E tests: - ErrorOverlay model count stays constant when a node is selected (new fixture `missing_models_distinct.json`) - Bypass/un-bypass cycle preserves Copy URL button (uses `missing_models_from_node_properties`) - Loading a workflow with bypassed subgraph suppresses interior missing model error (new fixture `missing_models_in_bypassed_subgraph.json`) - Entering a bypassed subgraph does not resurface interior missing model error (shares the above fixture) `pnpm typecheck`, `pnpm lint`, 206 related unit tests passing. ## Follow-up Several items raised by code review are deferred as pre-existing tech debt or scope-avoided refactors. Tracked via comments on #11215 and #11216. --- Follows up on #10856.
541 lines
16 KiB
TypeScript
541 lines
16 KiB
TypeScript
import type {
|
|
ComfyWorkflowJSON,
|
|
ModelFile,
|
|
NodeId
|
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import type {
|
|
MissingModelCandidate,
|
|
MissingModelViewModel,
|
|
EmbeddedModelWithSource
|
|
} 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 { getParentExecutionIds } from '@/types/nodeIdentification'
|
|
import {
|
|
collectAllNodes,
|
|
getExecutionIdByNode
|
|
} from '@/utils/graphTraversalUtil'
|
|
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
|
import { resolveComboValues } from '@/utils/litegraphUtil'
|
|
|
|
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'
|
|
}
|
|
|
|
// 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 (
|
|
node.mode === LGraphEventMode.NEVER ||
|
|
node.mode === LGraphEventMode.BYPASS
|
|
)
|
|
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 (!value.trim()) return null
|
|
if (!isModelFileName(value)) return null
|
|
|
|
return {
|
|
nodeId: executionId as NodeId,
|
|
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 as NodeId,
|
|
nodeType: node.type,
|
|
widgetName: widget.name,
|
|
isAssetSupported: nodeIsAssetSupported,
|
|
name: value,
|
|
directory: getDirectory?.(node.type),
|
|
isMissing: nodeIsAssetSupported ? undefined : !inOptions
|
|
}
|
|
}
|
|
|
|
export async function enrichWithEmbeddedMetadata(
|
|
candidates: readonly MissingModelCandidate[],
|
|
graphData: ComfyWorkflowJSON,
|
|
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
|
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
|
): Promise<MissingModelCandidate[]> {
|
|
const allNodes = flattenWorkflowNodes(graphData)
|
|
const embeddedModels = collectEmbeddedModelsWithSource(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: EmbeddedModelWithSource[] = []
|
|
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)
|
|
}
|
|
|
|
const unmatched: EmbeddedModelWithSource[] = []
|
|
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) {
|
|
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
|
|
}
|
|
} else {
|
|
unmatched.push(model)
|
|
}
|
|
}
|
|
|
|
// Workflow-level entries (sourceNodeType === '') survive only when
|
|
// some active (non-muted, non-bypassed) node actually references the
|
|
// model — not merely because any unrelated active node exists. A
|
|
// reference is any widget value (or node.properties.models entry)
|
|
// that matches the model name on an active node.
|
|
// Hoist the id→node map once; isModelReferencedByActiveNode would
|
|
// otherwise rebuild it on every unmatched entry.
|
|
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
|
|
const activeUnmatched = unmatched.filter(
|
|
(m) =>
|
|
m.sourceNodeType !== '' ||
|
|
isModelReferencedByActiveNode(
|
|
m.name,
|
|
m.directory,
|
|
allNodes,
|
|
flattenedNodeById
|
|
)
|
|
)
|
|
|
|
const settled = await Promise.allSettled(
|
|
activeUnmatched.map(async (model) => {
|
|
const installed = await checkModelInstalled(model.name, model.directory)
|
|
if (installed) return null
|
|
|
|
const nodeIsAssetSupported = isAssetSupported
|
|
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
|
|
: false
|
|
|
|
return {
|
|
nodeId: model.sourceNodeId,
|
|
nodeType: model.sourceNodeType,
|
|
widgetName: model.sourceWidgetName,
|
|
isAssetSupported: nodeIsAssetSupported,
|
|
name: model.name,
|
|
directory: model.directory,
|
|
url: model.url,
|
|
hash: model.hash,
|
|
hashType: model.hash_type,
|
|
isMissing: nodeIsAssetSupported ? undefined : true
|
|
} satisfies MissingModelCandidate
|
|
})
|
|
)
|
|
|
|
for (const r of settled) {
|
|
if (r.status === 'rejected') {
|
|
console.warn(
|
|
'[Missing Model Pipeline] checkModelInstalled failed:',
|
|
r.reason
|
|
)
|
|
continue
|
|
}
|
|
if (r.value) enriched.push(r.value)
|
|
}
|
|
|
|
return enriched
|
|
}
|
|
|
|
function isModelReferencedByActiveNode(
|
|
modelName: string,
|
|
modelDirectory: string | undefined,
|
|
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
|
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
|
): boolean {
|
|
for (const node of allNodes) {
|
|
if (
|
|
node.mode === LGraphEventMode.NEVER ||
|
|
node.mode === LGraphEventMode.BYPASS
|
|
)
|
|
continue
|
|
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
|
|
|
|
// Require directory agreement when both sides specify one, so a
|
|
// same-name entry under a different folder does not keep an
|
|
// unrelated workflow-level model alive as missing.
|
|
const embeddedModels = (
|
|
node.properties as
|
|
| { models?: Array<{ name: string; directory?: string }> }
|
|
| undefined
|
|
)?.models
|
|
if (
|
|
embeddedModels?.some(
|
|
(m) =>
|
|
m.name === modelName &&
|
|
(modelDirectory === undefined ||
|
|
m.directory === undefined ||
|
|
m.directory === modelDirectory)
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
// widgets_values carries only the name, so directory cannot be
|
|
// checked here — fall back to filename matching.
|
|
const values = node.widgets_values
|
|
if (!values) continue
|
|
const valueArray = Array.isArray(values) ? values : Object.values(values)
|
|
for (const v of valueArray) {
|
|
if (typeof v === 'string' && v === modelName) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function isAncestorPathActiveInFlattened(
|
|
executionId: string,
|
|
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
|
): boolean {
|
|
for (const ancestorId of getParentExecutionIds(executionId)) {
|
|
const ancestor = nodeById.get(ancestorId)
|
|
if (!ancestor) continue
|
|
if (
|
|
ancestor.mode === LGraphEventMode.NEVER ||
|
|
ancestor.mode === LGraphEventMode.BYPASS
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function collectEmbeddedModelsWithSource(
|
|
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
|
graphData: ComfyWorkflowJSON
|
|
): EmbeddedModelWithSource[] {
|
|
const result: EmbeddedModelWithSource[] = []
|
|
|
|
for (const node of allNodes) {
|
|
if (
|
|
node.mode === LGraphEventMode.NEVER ||
|
|
node.mode === LGraphEventMode.BYPASS
|
|
)
|
|
continue
|
|
|
|
const selected = getSelectedModelsMetadata(
|
|
node as Parameters<typeof getSelectedModelsMetadata>[0]
|
|
)
|
|
if (!selected?.length) continue
|
|
|
|
for (const model of selected) {
|
|
result.push({
|
|
...model,
|
|
sourceNodeId: node.id,
|
|
sourceNodeType: node.type,
|
|
sourceWidgetName: findWidgetNameForModel(node, model.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Workflow-level model entries have no originating node; sourceNodeId
|
|
// remains undefined and empty-string node type/widget are handled by
|
|
// groupCandidatesByName (no nodeId → no referencing node entry).
|
|
if (graphData.models?.length) {
|
|
for (const model of graphData.models) {
|
|
result.push({
|
|
...model,
|
|
sourceNodeType: '',
|
|
sourceWidgetName: ''
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function findWidgetNameForModel(
|
|
node: ReturnType<typeof flattenWorkflowNodes>[number],
|
|
modelName: string
|
|
): string {
|
|
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
|
|
const wv = node.widgets_values as Record<string, unknown>
|
|
for (const [key, val] of Object.entries(wv)) {
|
|
if (val === modelName) return key
|
|
}
|
|
return ''
|
|
}
|
|
|
|
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 pendingNodeTypes = new Set<string>()
|
|
for (const c of candidates) {
|
|
if (c.isAssetSupported && c.isMissing === undefined) {
|
|
pendingNodeTypes.add(c.nodeType)
|
|
}
|
|
}
|
|
|
|
if (pendingNodeTypes.size === 0) return
|
|
|
|
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.asset_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())
|
|
}
|