mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
## Summary Backend validation errors (`node_errors`) are reported against the **flattened** prompt, so an error whose real fix lives on a subgraph host node gets attached to an interior node the user may never open — and, after ADR 0009, often *cannot* meaningfully fix there. This PR re-surfaces a validation error onto the subgraph host node **when — and only when — the error's subject (the specific input/widget named by `extra_info.input_name`) is exposed through the subgraph boundary**. ## Why this is needed Two concrete situations motivated this, both observed in real workflows: 1. **Broken link at the host.** Root node A should feed subgraph host B, whose boundary input is linked to interior node C. If the A→B link is missing, the backend flattens the prompt, sees C with no resolved input, and raises `required_input_missing` **on C** (`"B:C"`). The actual fix — connect B's input — is one level up, on a node the error never points at. 2. **Host-owned widget values.** Per ADR 0009 (subgraph promoted widgets use linked inputs), a promoted widget's value is owned by the host `SubgraphNode`; the interior widget only supplies schema/defaults. When the backend raises `value_not_in_list` (or min/max violations) for that value, attributing it to the interior node is factually wrong — the value that failed validation *is the host's value*. This continues the direction of #13059, which moved **missing-model** detection identity to `{hostExecutionId, hostWidgetName}` with the interior path kept as diagnostics. That was possible in the FE pre-scan; this PR applies the same ownership principle to **backend-received** errors via a receive-side mapping, since the backend cannot know about subgraph boundaries in a flattened prompt. ## The rule (design) > Lift an error from interior node N to host H **iff** N's input slot named by the error is linked to the containing subgraph's boundary (`SubgraphInput`). Apply the same test again at H (boundary-by-boundary, matching ADR 0009's chaining principle) and stop at the first level where the subject is no longer boundary-linked — that node is where the user can actually fix it. The predicate is **structural (boundary exposure), not data-flow**. ### In scope — examples - `required_input_missing` on interior `"12:5"` whose input is fed by the boundary → surfaces on host `12`'s input slot (red slot ring on the host, errors-tab card titled/located at the host, message names the host's `SubgraphInput.name`). - `value_not_in_list` / `value_smaller_than_min` / `value_bigger_than_max` on a promoted interior widget → surfaces on the host's promoted widget. Nested hosts chain: `"1:2:3"` lifts to `"1:2"`, and further to `"1"` only if `1:2`'s own slot is boundary-linked too. - Clearing follows the surface: connecting the highlighted host input or fixing the host widget clears the underlying interior (raw) error — range-guarded per target, so a still-out-of-range host value does **not** clear. ### Out of scope — examples - **No value-flow ancestry.** All in the root graph: A's widget links to B, B's to C, and C rejects the value that originated at A → the error **stays on C**. Following same-graph links to a "root cause" node is explicitly not this feature. - Errors without an `input_name` subject, node-level types (`exception_during_validation`, `dependency_cycle`, image-not-loaded), and unknown validation types — never lifted. Unknown types stay node-scoped to match how the error catalog renders them (the shared `isNodeLevelValidationError` in `executionErrorUtil` encodes this, and the catalog derives its node-level rules from the same set). - Runtime execution errors (exceptions during a run) — validation responses only. - Interior errors whose input is fed by another interior node — fixable in place, stay in place. - Fan-out display dedupe: when one boundary input feeds multiple interior nodes and the host slot is unconnected, each interior error lifts to the same host slot as a separate panel line. A single fix (connecting the host input) clears all of them — the clearing translation already fans out — so the duplication is cosmetic. Display-level dedupe is a follow-up; deduping inside the lift would break the one-source-per-error clearing contract. - Reactive re-lifting on graph topology edits while errors are displayed (invariant documented on the computed; follow-up), and deriving the error catalog's full validation rule table from the shared classification (follow-up; the node-level type set and the image-not-loaded predicate are already single-sourced in `executionErrorUtil` and consumed by both the lift and the catalog). ## Changes - **What**: New pure module `core/graph/subgraph/liftNodeErrorsToBoundary.ts` — per-error, fail-open record transform (unresolvable ids/slots/links leave the error where the backend put it; raw payload is never mutated). `executionErrorStore` derives `surfacedNodeErrors` from it and display consumers switch over (errors tab grouping, canvas node/slot flags, Vue node badges, app-mode/linear hints); raw `lastNodeErrors` remains the source of truth for mutation. Host-side clearing translates through the lift's diagnostics fields (`source_execution_id` / `source_input_name`) with a per-target range guard. - **Breaking**: None. No persistence/serialization changes; interior identity survives as diagnostics metadata only (ADR 0009 language). ## Review Focus - The lift predicate lives entirely on link topology (`LLink.originIsIoNode` → `SubgraphInput`) — no `proxyWidgets`/promotion-store style source authority is reintroduced. - `clearSlotErrorsWithRangeCheck` now resolves clear targets first and range-checks each target's raw errors; the lifted-path twin of the existing range-retention test pins this. - `useProcessedWidgets` deliberately stays on the raw record: host promoted widgets already map to interior errors via `widget.sourceExecutionId`, so an interior widget keeps its local red hint when the user opens the subgraph (hint layer vs surface layer). - Unit coverage is carried by the pure module (real litegraph subgraph fixtures, incl. nested recursion, promoted widgets via `promoteValueWidgetViaSubgraphInput`, ordering, fail-open/no-mutation); one e2e pins the user-visible contract (host ring + host slot dot + interior clean). ## Screenshots ### Before https://github.com/user-attachments/assets/81e5c4db-515d-4f1f-8f8a-e07ac490510f ### After https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
917 lines
27 KiB
TypeScript
917 lines
27 KiB
TypeScript
import { computed, reactive, ref, toValue, watch } from 'vue'
|
|
import type { MaybeRefOrGetter } from 'vue'
|
|
import Fuse from 'fuse.js'
|
|
import type { IFuseOptions } from 'fuse.js'
|
|
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
|
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
|
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { app } from '@/scripts/app'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
|
|
import {
|
|
getNodeByExecutionId,
|
|
getExecutionIdByNode
|
|
} from '@/utils/graphTraversalUtil'
|
|
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
|
import { isLGraphNode } from '@/utils/litegraphUtil'
|
|
import { st } from '@/i18n'
|
|
import type { MissingNodeType } from '@/types/comfy'
|
|
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
|
import { shouldRenderExecutionItemList } from './executionItemList'
|
|
import { someNodeTypeInSelection } from './selectionEmphasis'
|
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
|
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
|
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
|
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
|
import {
|
|
countMissingModels,
|
|
groupMissingModelCandidates
|
|
} from '@/platform/missingModel/missingModelGrouping'
|
|
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
|
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
|
import {
|
|
resolveMissingErrorMessage,
|
|
resolveRunErrorMessage
|
|
} from '@/platform/errorCatalog/errorMessageResolver'
|
|
import {
|
|
compareExecutionId,
|
|
tryNormalizeNodeExecutionId
|
|
} from '@/types/nodeIdentification'
|
|
|
|
const PROMPT_CARD_ID = '__prompt__'
|
|
|
|
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
|
const RESOLVING = '__RESOLVING__'
|
|
|
|
export interface MissingPackGroup {
|
|
packId: string | null
|
|
nodeTypes: MissingNodeType[]
|
|
isResolving: boolean
|
|
}
|
|
|
|
export interface SwapNodeGroup {
|
|
type: string
|
|
newNodeId: string | undefined
|
|
nodeTypes: MissingNodeType[]
|
|
}
|
|
|
|
interface GroupEntry {
|
|
type: 'execution'
|
|
displayTitle: string
|
|
displayMessage?: string
|
|
priority: number
|
|
cards: Map<string, ErrorCardData>
|
|
}
|
|
|
|
interface ErrorSearchItem {
|
|
groupIndex: number
|
|
cardIndex: number
|
|
searchableNodeId: string
|
|
searchableNodeTitle: string
|
|
searchableRawMessage: string
|
|
searchableRawDetails: string
|
|
searchableMessage: string
|
|
searchableDetails: string
|
|
}
|
|
|
|
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
|
|
|
/** Resolve display info for a node by its execution ID. */
|
|
function resolveNodeInfo(nodeId: NodeExecutionId) {
|
|
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
|
|
|
return {
|
|
title: resolveNodeDisplayName(graphNode, {
|
|
emptyLabel: '',
|
|
untitledLabel: '',
|
|
st
|
|
}),
|
|
graphNodeId: graphNode ? String(graphNode.id) : undefined
|
|
}
|
|
}
|
|
|
|
function getOrCreateGroup(
|
|
groupsMap: Map<string, GroupEntry>,
|
|
groupKey: string,
|
|
displayTitle = groupKey,
|
|
priority = 1,
|
|
displayMessage?: string
|
|
): Map<string, ErrorCardData> {
|
|
let entry = groupsMap.get(groupKey)
|
|
if (!entry) {
|
|
entry = {
|
|
type: 'execution',
|
|
displayTitle,
|
|
displayMessage,
|
|
priority,
|
|
cards: new Map()
|
|
}
|
|
groupsMap.set(groupKey, entry)
|
|
} else if (!entry.displayMessage && displayMessage) {
|
|
entry.displayMessage = displayMessage
|
|
}
|
|
return entry.cards
|
|
}
|
|
|
|
function createErrorCard(
|
|
nodeId: NodeExecutionId,
|
|
classType: string,
|
|
idPrefix: string
|
|
): ErrorCardData {
|
|
const nodeInfo = resolveNodeInfo(nodeId)
|
|
return {
|
|
id: `${idPrefix}-${nodeId}`,
|
|
title: classType,
|
|
nodeId,
|
|
nodeTitle: nodeInfo.title,
|
|
graphNodeId: nodeInfo.graphNodeId,
|
|
errors: []
|
|
}
|
|
}
|
|
|
|
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
|
return compareExecutionId(a.nodeId, b.nodeId)
|
|
}
|
|
|
|
function countExecutionCards(cards: ErrorCardData[]): number {
|
|
if (shouldRenderExecutionItemList(cards)) {
|
|
return cards.reduce((count, card) => count + card.errors.length, 0)
|
|
}
|
|
|
|
return cards.length
|
|
}
|
|
|
|
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
|
return Array.from(groupsMap.entries())
|
|
.map(([rawGroupKey, groupData]) => {
|
|
const cards = Array.from(groupData.cards.values()).sort(compareNodeId)
|
|
return {
|
|
type: 'execution' as const,
|
|
groupKey: `execution:${rawGroupKey}`,
|
|
displayTitle: groupData.displayTitle,
|
|
displayMessage: groupData.displayMessage,
|
|
count: countExecutionCards(cards),
|
|
cards,
|
|
priority: groupData.priority
|
|
}
|
|
})
|
|
.sort((a, b) => {
|
|
if (a.priority !== b.priority) return a.priority - b.priority
|
|
return a.displayTitle.localeCompare(b.displayTitle)
|
|
})
|
|
}
|
|
|
|
function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
|
if (!query) return groups
|
|
|
|
const searchableList: ErrorSearchItem[] = []
|
|
for (let gi = 0; gi < groups.length; gi++) {
|
|
const group = groups[gi]
|
|
if (group.type !== 'execution') continue
|
|
for (let ci = 0; ci < group.cards.length; ci++) {
|
|
const card = group.cards[ci]
|
|
searchableList.push({
|
|
groupIndex: gi,
|
|
cardIndex: ci,
|
|
searchableNodeId: card.nodeId ?? '',
|
|
searchableNodeTitle: card.nodeTitle ?? '',
|
|
searchableRawMessage: card.errors.map((e) => e.message).join(' '),
|
|
searchableRawDetails: card.errors.map((e) => e.details).join(' '),
|
|
searchableMessage: card.errors
|
|
.map((e) =>
|
|
[e.displayTitle, e.displayMessage, e.message]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
)
|
|
.join(' '),
|
|
searchableDetails: card.errors
|
|
.map((e) => [e.displayDetails, e.details].filter(Boolean).join(' '))
|
|
.join(' ')
|
|
})
|
|
}
|
|
}
|
|
|
|
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
|
|
keys: [
|
|
{ name: 'searchableRawMessage', weight: 0.3 },
|
|
{ name: 'searchableNodeId', weight: 0.2 },
|
|
{ name: 'searchableNodeTitle', weight: 0.2 },
|
|
{ name: 'searchableMessage', weight: 0.2 },
|
|
{ name: 'searchableRawDetails', weight: 0.1 },
|
|
{ name: 'searchableDetails', weight: 0.1 }
|
|
],
|
|
threshold: 0.3
|
|
}
|
|
|
|
const fuse = new Fuse(searchableList, fuseOptions)
|
|
const results = fuse.search(query)
|
|
|
|
const matchedCardKeys = new Set(
|
|
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
|
|
)
|
|
|
|
return groups
|
|
.map((group, gi) => {
|
|
if (group.type !== 'execution') return group
|
|
const cards = group.cards.filter((_: ErrorCardData, ci: number) =>
|
|
matchedCardKeys.has(`${gi}:${ci}`)
|
|
)
|
|
return {
|
|
...group,
|
|
cards,
|
|
count: countExecutionCards(cards)
|
|
}
|
|
})
|
|
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
|
|
}
|
|
|
|
export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
|
const executionErrorStore = useExecutionErrorStore()
|
|
const missingNodesStore = useMissingNodesErrorStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
const missingMediaStore = useMissingMediaStore()
|
|
const canvasStore = useCanvasStore()
|
|
const { inferPackFromNodeName } = useComfyRegistryStore()
|
|
const collapseState = reactive<Record<string, boolean>>({})
|
|
|
|
const selectedNodeInfo = computed(() => {
|
|
const items = canvasStore.selectedItems
|
|
const nodeIds = new Set<string>()
|
|
const containerExecutionIds = new Set<NodeExecutionId>()
|
|
|
|
for (const item of items) {
|
|
if (!isLGraphNode(item)) continue
|
|
nodeIds.add(String(item.id))
|
|
if (item instanceof SubgraphNode && app.rootGraph) {
|
|
const execId = getExecutionIdByNode(app.rootGraph, item)
|
|
if (execId) containerExecutionIds.add(execId)
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
|
containerExecutionIds
|
|
}
|
|
})
|
|
|
|
const hasSelection = computed(() => selectedNodeInfo.value.nodeIds !== null)
|
|
|
|
const selectedNodeCount = computed(
|
|
() => selectedNodeInfo.value.nodeIds?.size ?? 0
|
|
)
|
|
|
|
const selectedNodeTitle = computed(() => {
|
|
if (selectedNodeCount.value !== 1) return null
|
|
const node = canvasStore.selectedItems.find(isLGraphNode)
|
|
if (!node) return null
|
|
return (
|
|
resolveNodeDisplayName(node, {
|
|
emptyLabel: '',
|
|
untitledLabel: '',
|
|
st
|
|
}) || null
|
|
)
|
|
})
|
|
|
|
const errorNodeCache = computed(() => {
|
|
const map = new Map<string, LGraphNode>()
|
|
for (const execId of executionErrorStore.allErrorExecutionIds) {
|
|
const node = getNodeByExecutionId(app.rootGraph, execId)
|
|
if (node) map.set(execId, node)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const missingNodeCache = computed(() => {
|
|
const map = new Map<string, LGraphNode>()
|
|
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
|
for (const nodeType of nodeTypes) {
|
|
if (typeof nodeType === 'string') continue
|
|
if (nodeType.nodeId == null) continue
|
|
const nodeId = String(nodeType.nodeId)
|
|
const node = getNodeByExecutionId(app.rootGraph, nodeId)
|
|
if (node) map.set(nodeId, node)
|
|
}
|
|
return map
|
|
})
|
|
|
|
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
|
const nodeIds = selectedNodeInfo.value.nodeIds
|
|
if (!nodeIds) return true
|
|
|
|
const graphNode = errorNodeCache.value.get(executionNodeId)
|
|
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
|
|
|
for (const containerExecId of selectedNodeInfo.value
|
|
.containerExecutionIds) {
|
|
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function addNodeErrorToGroup(
|
|
groupsMap: Map<string, GroupEntry>,
|
|
nodeId: NodeExecutionId,
|
|
classType: string,
|
|
idPrefix: string,
|
|
error: CataloguedErrorItem,
|
|
filterBySelection = false
|
|
) {
|
|
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
|
const cards = getOrCreateGroup(
|
|
groupsMap,
|
|
error.catalogId,
|
|
error.displayTitle ?? classType,
|
|
1,
|
|
error.displayMessage
|
|
)
|
|
if (!cards.has(nodeId)) {
|
|
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
|
}
|
|
const card = cards.get(nodeId)
|
|
if (!card) return
|
|
card.errors.push(error)
|
|
}
|
|
|
|
function processPromptError(
|
|
groupsMap: Map<string, GroupEntry>,
|
|
filterBySelection = false
|
|
) {
|
|
if (
|
|
(filterBySelection && selectedNodeInfo.value.nodeIds) ||
|
|
!executionErrorStore.lastPromptError
|
|
)
|
|
return
|
|
|
|
const error = executionErrorStore.lastPromptError
|
|
const resolvedDisplay = resolveRunErrorMessage({
|
|
kind: 'prompt',
|
|
error,
|
|
isCloud
|
|
})
|
|
const groupDisplayTitle = resolvedDisplay.displayTitle ?? error.message
|
|
const cards = getOrCreateGroup(
|
|
groupsMap,
|
|
`prompt:${error.type}`,
|
|
groupDisplayTitle,
|
|
0,
|
|
resolvedDisplay.displayMessage
|
|
)
|
|
|
|
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
|
cards.set(PROMPT_CARD_ID, {
|
|
id: PROMPT_CARD_ID,
|
|
title: groupDisplayTitle,
|
|
errors: [
|
|
{
|
|
message: error.message,
|
|
...resolvedDisplay
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
function processNodeErrors(
|
|
groupsMap: Map<string, GroupEntry>,
|
|
filterBySelection = false
|
|
) {
|
|
if (!executionErrorStore.surfacedNodeErrors) return
|
|
|
|
for (const [rawNodeId, nodeError] of Object.entries(
|
|
executionErrorStore.surfacedNodeErrors
|
|
)) {
|
|
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
|
if (!nodeId) continue
|
|
const nodeDisplayName =
|
|
resolveNodeInfo(nodeId).title || nodeError.class_type
|
|
for (const e of nodeError.errors) {
|
|
addNodeErrorToGroup(
|
|
groupsMap,
|
|
nodeId,
|
|
nodeError.class_type,
|
|
'node',
|
|
{
|
|
message: e.message,
|
|
details: e.details ?? undefined,
|
|
...resolveRunErrorMessage({
|
|
kind: 'node_validation',
|
|
error: e,
|
|
nodeDisplayName
|
|
})
|
|
},
|
|
filterBySelection
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function processExecutionError(
|
|
groupsMap: Map<string, GroupEntry>,
|
|
filterBySelection = false
|
|
) {
|
|
if (!executionErrorStore.lastExecutionError) return
|
|
|
|
const e = executionErrorStore.lastExecutionError
|
|
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
|
|
if (!nodeId) return
|
|
|
|
addNodeErrorToGroup(
|
|
groupsMap,
|
|
nodeId,
|
|
e.node_type,
|
|
'exec',
|
|
{
|
|
message: `${e.exception_type}: ${e.exception_message}`,
|
|
details: e.traceback.join('\n'),
|
|
isRuntimeError: true,
|
|
exceptionType: e.exception_type,
|
|
...resolveRunErrorMessage({
|
|
kind: 'execution',
|
|
error: e,
|
|
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
|
|
})
|
|
},
|
|
filterBySelection
|
|
)
|
|
}
|
|
|
|
// Async pack-ID resolution for missing node types that lack a cnrId
|
|
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
|
|
|
|
const pendingTypes = computed(() =>
|
|
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
|
|
(n): n is Exclude<MissingNodeType, string> =>
|
|
typeof n !== 'string' && !n.cnrId
|
|
)
|
|
)
|
|
|
|
watch(
|
|
pendingTypes,
|
|
async (pending, _, onCleanup) => {
|
|
const toResolve = pending.filter(
|
|
(n) => asyncResolvedIds.value.get(n.type) === undefined
|
|
)
|
|
if (!toResolve.length) return
|
|
|
|
const resolvingTypes = toResolve.map((n) => n.type)
|
|
let cancelled = false
|
|
onCleanup(() => {
|
|
cancelled = true
|
|
const next = new Map(asyncResolvedIds.value)
|
|
for (const type of resolvingTypes) {
|
|
if (next.get(type) === RESOLVING) next.delete(type)
|
|
}
|
|
asyncResolvedIds.value = next
|
|
})
|
|
|
|
const updated = new Map(asyncResolvedIds.value)
|
|
for (const type of resolvingTypes) updated.set(type, RESOLVING)
|
|
asyncResolvedIds.value = updated
|
|
|
|
const results = await Promise.allSettled(
|
|
toResolve.map(async (n) => ({
|
|
type: n.type,
|
|
packId: (await inferPackFromNodeName.call(n.type))?.id ?? null
|
|
}))
|
|
)
|
|
if (cancelled) return
|
|
|
|
const final = new Map(asyncResolvedIds.value)
|
|
for (const r of results) {
|
|
if (r.status === 'fulfilled') {
|
|
final.set(r.value.type, r.value.packId)
|
|
} else {
|
|
console.warn('Failed to resolve pack ID:', r.reason)
|
|
}
|
|
}
|
|
// Clear any remaining RESOLVING markers for failed lookups
|
|
for (const type of resolvingTypes) {
|
|
if (final.get(type) === RESOLVING) final.set(type, null)
|
|
}
|
|
asyncResolvedIds.value = final
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
// Evict stale entries when missing nodes are cleared
|
|
watch(
|
|
() => missingNodesStore.missingNodesError,
|
|
(error) => {
|
|
if (!error && asyncResolvedIds.value.size > 0) {
|
|
asyncResolvedIds.value = new Map()
|
|
}
|
|
}
|
|
)
|
|
|
|
const missingPackGroups = computed<MissingPackGroup[]>(() => {
|
|
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
|
const map = new Map<
|
|
string | null,
|
|
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
|
|
>()
|
|
const resolvingKeys = new Set<string | null>()
|
|
|
|
for (const nodeType of nodeTypes) {
|
|
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
|
|
|
|
let packId: string | null
|
|
|
|
if (typeof nodeType === 'string') {
|
|
packId = null
|
|
} else if (nodeType.cnrId) {
|
|
packId = nodeType.cnrId
|
|
} else {
|
|
const resolved = asyncResolvedIds.value.get(nodeType.type)
|
|
if (resolved === undefined || resolved === RESOLVING) {
|
|
packId = null
|
|
resolvingKeys.add(null)
|
|
} else {
|
|
packId = resolved
|
|
}
|
|
}
|
|
|
|
const existing = map.get(packId)
|
|
if (existing) {
|
|
existing.nodeTypes.push(nodeType)
|
|
} else {
|
|
map.set(packId, { nodeTypes: [nodeType], isResolving: false })
|
|
}
|
|
}
|
|
|
|
for (const key of resolvingKeys) {
|
|
const group = map.get(key)
|
|
if (group) group.isResolving = true
|
|
}
|
|
|
|
return Array.from(map.entries())
|
|
.sort(([packIdA], [packIdB]) => {
|
|
// null (Unknown Pack) always goes last
|
|
if (packIdA === null) return 1
|
|
if (packIdB === null) return -1
|
|
return packIdA.localeCompare(packIdB)
|
|
})
|
|
.map(([packId, { nodeTypes, isResolving }]) => ({
|
|
packId,
|
|
nodeTypes: [...nodeTypes].sort((a, b) => {
|
|
const typeA = typeof a === 'string' ? a : a.type
|
|
const typeB = typeof b === 'string' ? b : b.type
|
|
const typeCmp = typeA.localeCompare(typeB)
|
|
if (typeCmp !== 0) return typeCmp
|
|
const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '')
|
|
const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '')
|
|
return idA.localeCompare(idB, undefined, { numeric: true })
|
|
}),
|
|
isResolving
|
|
}))
|
|
})
|
|
|
|
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
|
|
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
|
const map = new Map<string, SwapNodeGroup>()
|
|
|
|
for (const nodeType of nodeTypes) {
|
|
if (typeof nodeType === 'string' || !nodeType.isReplaceable) continue
|
|
|
|
const typeName = nodeType.type
|
|
const existing = map.get(typeName)
|
|
if (existing) {
|
|
existing.nodeTypes.push(nodeType)
|
|
} else {
|
|
map.set(typeName, {
|
|
type: typeName,
|
|
newNodeId: nodeType.replacement?.new_node_id,
|
|
nodeTypes: [nodeType]
|
|
})
|
|
}
|
|
}
|
|
|
|
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
|
|
})
|
|
|
|
/**
|
|
* Builds ErrorGroups from missingNodesError. Returns [] when none present.
|
|
* `includeGroup` narrows which swap/pack groups are counted (used to scope
|
|
* emphasis to the canvas selection); groups reduced to zero are omitted.
|
|
*/
|
|
function buildMissingNodeGroups(
|
|
includeGroup: (nodeTypes: MissingNodeType[]) => boolean = () => true
|
|
): ErrorGroup[] {
|
|
const error = missingNodesStore.missingNodesError
|
|
if (!error) return []
|
|
|
|
const groups: ErrorGroup[] = []
|
|
const swapCount = swapNodeGroups.value.filter((group) =>
|
|
includeGroup(group.nodeTypes)
|
|
).length
|
|
const packCount = missingPackGroups.value.filter((group) =>
|
|
includeGroup(group.nodeTypes)
|
|
).length
|
|
|
|
if (swapCount > 0) {
|
|
groups.push({
|
|
type: 'swap_nodes' as const,
|
|
groupKey: 'swap_nodes',
|
|
count: swapCount,
|
|
priority: 0,
|
|
...resolveMissingErrorMessage({
|
|
kind: 'swap_nodes',
|
|
nodeTypes: error.nodeTypes,
|
|
count: swapCount,
|
|
isCloud
|
|
})
|
|
})
|
|
}
|
|
|
|
if (packCount > 0) {
|
|
groups.push({
|
|
type: 'missing_node' as const,
|
|
groupKey: 'missing_node',
|
|
count: packCount,
|
|
priority: 1,
|
|
...resolveMissingErrorMessage({
|
|
kind: 'missing_node',
|
|
nodeTypes: error.nodeTypes,
|
|
count: packCount,
|
|
isCloud
|
|
})
|
|
})
|
|
}
|
|
|
|
return groups.sort((a, b) => a.priority - b.priority)
|
|
}
|
|
|
|
const missingModelGroups = computed<MissingModelGroup[]>(() => {
|
|
return groupMissingModelCandidates(
|
|
missingModelStore.missingModelCandidates,
|
|
isCloud
|
|
)
|
|
})
|
|
|
|
function buildMissingModelGroups(): ErrorGroup[] {
|
|
if (!missingModelGroups.value.length) return []
|
|
const count = countMissingModels(missingModelGroups.value)
|
|
return [
|
|
{
|
|
type: 'missing_model' as const,
|
|
groupKey: 'missing_model',
|
|
count,
|
|
priority: 2,
|
|
...resolveMissingErrorMessage({
|
|
kind: 'missing_model',
|
|
groups: missingModelGroups.value,
|
|
count,
|
|
isCloud
|
|
})
|
|
}
|
|
]
|
|
}
|
|
|
|
const missingMediaGroups = computed<MissingMediaGroup[]>(() => {
|
|
const candidates = missingMediaStore.missingMediaCandidates
|
|
if (!candidates?.length) return []
|
|
return groupCandidatesByMediaType(candidates)
|
|
})
|
|
|
|
function buildMissingMediaGroups(): ErrorGroup[] {
|
|
if (!missingMediaGroups.value.length) return []
|
|
const totalRows = countMissingMediaReferences(missingMediaGroups.value)
|
|
return [
|
|
{
|
|
type: 'missing_media' as const,
|
|
groupKey: 'missing_media',
|
|
count: totalRows,
|
|
priority: 3,
|
|
...resolveMissingErrorMessage({
|
|
kind: 'missing_media',
|
|
groups: missingMediaGroups.value,
|
|
count: totalRows,
|
|
isCloud
|
|
})
|
|
}
|
|
]
|
|
}
|
|
|
|
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
|
const nodeIds = selectedNodeInfo.value.nodeIds
|
|
if (!nodeIds) return true
|
|
|
|
// Try missing node cache first
|
|
const cachedNode = missingNodeCache.value.get(executionNodeId)
|
|
if (cachedNode && nodeIds.has(String(cachedNode.id))) return true
|
|
|
|
// Resolve from graph for model/media candidates
|
|
if (app.rootGraph) {
|
|
const graphNode = getNodeByExecutionId(app.rootGraph, executionNodeId)
|
|
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
|
}
|
|
|
|
for (const containerExecId of selectedNodeInfo.value
|
|
.containerExecutionIds) {
|
|
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function isAssetCandidateInSelection(nodeId: string | number): boolean {
|
|
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
|
|
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
|
}
|
|
|
|
/** Model groups narrowed to the selection, for emphasis derivation only. */
|
|
const missingModelGroupsForSelection = computed(() => {
|
|
if (!hasSelection.value) return []
|
|
const candidates = missingModelStore.missingModelCandidates
|
|
if (!candidates?.length) return []
|
|
const matched = candidates.filter(
|
|
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
|
)
|
|
if (!matched.length) return []
|
|
return groupMissingModelCandidates(matched, isCloud)
|
|
})
|
|
|
|
/** Media groups narrowed to the selection, for emphasis derivation only. */
|
|
const missingMediaGroupsForSelection = computed(() => {
|
|
if (!hasSelection.value) return []
|
|
const candidates = missingMediaStore.missingMediaCandidates
|
|
if (!candidates?.length) return []
|
|
const matched = candidates.filter(
|
|
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
|
)
|
|
if (!matched.length) return []
|
|
return groupCandidatesByMediaType(matched)
|
|
})
|
|
|
|
function buildMissingModelGroupsForSelection(): ErrorGroup[] {
|
|
if (!missingModelGroupsForSelection.value.length) return []
|
|
const count = countMissingModels(missingModelGroupsForSelection.value)
|
|
return [
|
|
{
|
|
type: 'missing_model' as const,
|
|
groupKey: 'missing_model',
|
|
count,
|
|
priority: 2,
|
|
...resolveMissingErrorMessage({
|
|
kind: 'missing_model',
|
|
groups: missingModelGroupsForSelection.value,
|
|
count,
|
|
isCloud
|
|
})
|
|
}
|
|
]
|
|
}
|
|
|
|
function buildMissingMediaGroupsForSelection(): ErrorGroup[] {
|
|
if (!missingMediaGroupsForSelection.value.length) return []
|
|
const totalRows = countMissingMediaReferences(
|
|
missingMediaGroupsForSelection.value
|
|
)
|
|
return [
|
|
{
|
|
type: 'missing_media' as const,
|
|
groupKey: 'missing_media',
|
|
count: totalRows,
|
|
priority: 3,
|
|
...resolveMissingErrorMessage({
|
|
kind: 'missing_media',
|
|
groups: missingMediaGroupsForSelection.value,
|
|
count: totalRows,
|
|
isCloud
|
|
})
|
|
}
|
|
]
|
|
}
|
|
|
|
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
|
const groupsMap = new Map<string, GroupEntry>()
|
|
|
|
processPromptError(groupsMap)
|
|
processNodeErrors(groupsMap)
|
|
processExecutionError(groupsMap)
|
|
|
|
return [
|
|
...buildMissingNodeGroups(),
|
|
...buildMissingModelGroups(),
|
|
...buildMissingMediaGroups(),
|
|
...toSortedGroups(groupsMap)
|
|
]
|
|
})
|
|
|
|
/**
|
|
* The subset of error groups whose errors belong to the current canvas
|
|
* selection. Empty when nothing is selected. Display always shows all
|
|
* groups; this subset only drives selection emphasis (auto-expand, card
|
|
* highlight, context strip).
|
|
*/
|
|
const selectionScopedGroups = computed<ErrorGroup[]>(() => {
|
|
if (!hasSelection.value) return []
|
|
|
|
const groupsMap = new Map<string, GroupEntry>()
|
|
processPromptError(groupsMap, true)
|
|
processNodeErrors(groupsMap, true)
|
|
processExecutionError(groupsMap, true)
|
|
|
|
return [
|
|
...buildMissingNodeGroups((nodeTypes) =>
|
|
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
|
|
),
|
|
...buildMissingModelGroupsForSelection(),
|
|
...buildMissingMediaGroupsForSelection(),
|
|
...toSortedGroups(groupsMap)
|
|
]
|
|
})
|
|
|
|
/**
|
|
* Execution node ids referenced by any missing-asset candidate (models,
|
|
* media, missing node types).
|
|
*/
|
|
const assetNodeIdsWithError = computed<string[]>(() => {
|
|
const candidateIds = [
|
|
...(missingModelStore.missingModelCandidates ?? []),
|
|
...(missingMediaStore.missingMediaCandidates ?? [])
|
|
].map((candidate) => candidate.nodeId)
|
|
const missingNodeTypeIds = (
|
|
missingNodesStore.missingNodesError?.nodeTypes ?? []
|
|
).map((nodeType) =>
|
|
typeof nodeType === 'string' ? undefined : nodeType.nodeId
|
|
)
|
|
return [...candidateIds, ...missingNodeTypeIds]
|
|
.filter((nodeId) => nodeId != null)
|
|
.map(String)
|
|
})
|
|
|
|
/**
|
|
* Asset node ids that belong to the current selection. Drives row-level
|
|
* highlighting inside the missing-* cards.
|
|
*/
|
|
const selectionMatchedAssetNodeIds = computed<Set<string>>(() => {
|
|
if (!hasSelection.value) return new Set()
|
|
return new Set(
|
|
assetNodeIdsWithError.value.filter(isAssetCandidateInSelection)
|
|
)
|
|
})
|
|
|
|
const selectionMatchedGroupKeys = computed<Set<string>>(() => {
|
|
if (!hasSelection.value) return new Set()
|
|
return new Set(selectionScopedGroups.value.map((group) => group.groupKey))
|
|
})
|
|
|
|
const selectionMatchedCardIds = computed<Set<string>>(() => {
|
|
if (!hasSelection.value) return new Set()
|
|
return new Set(
|
|
selectionScopedGroups.value
|
|
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
|
|
.map((card) => card.id)
|
|
)
|
|
})
|
|
|
|
const selectionErrorCount = computed(() => {
|
|
if (!hasSelection.value) return 0
|
|
return selectionScopedGroups.value.reduce(
|
|
(sum, group) => sum + group.count,
|
|
0
|
|
)
|
|
})
|
|
|
|
/** Distinct nodes affected by any error (workflow-level summary). */
|
|
const errorNodeCount = computed(() => {
|
|
const executionNodeIds = allErrorGroups.value
|
|
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
|
|
.map((card) => card.nodeId)
|
|
.filter((nodeId) => nodeId != null)
|
|
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
|
|
})
|
|
|
|
const filteredGroups = computed<ErrorGroup[]>(() => {
|
|
const query = toValue(searchQuery).trim()
|
|
return searchErrorGroups(allErrorGroups.value, query)
|
|
})
|
|
|
|
return {
|
|
allErrorGroups,
|
|
filteredGroups,
|
|
collapseState,
|
|
errorNodeCache,
|
|
missingNodeCache,
|
|
missingPackGroups,
|
|
missingModelGroups,
|
|
missingMediaGroups,
|
|
swapNodeGroups,
|
|
hasSelection,
|
|
selectedNodeCount,
|
|
selectedNodeTitle,
|
|
selectionMatchedGroupKeys,
|
|
selectionMatchedCardIds,
|
|
selectionMatchedAssetNodeIds,
|
|
selectionErrorCount,
|
|
errorNodeCount
|
|
}
|
|
}
|