mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-28 10:37:16 +00:00
## Summary Simplifies the Missing Models error card as the fifth slice of the catalog-driven error-tab redesign. This PR is intentionally larger than the previous slices because Missing Models is the only remaining error type where the card UI, OSS download flow, Cloud import flow, and shared model-import dialog all have to move together to preserve the resolution path. The high-level goal is to make Missing Models behave like the other simplified error cards: show the exact missing item, show the affected nodes, keep locate actions predictable, and only expose actions that can actually resolve the problem. This follows the staged error-tab cleanup plan: 1. #12683 refined validation, runtime, and prompt error presentation. 2. #12705 simplified missing media error presentation. 3. #12735 simplified missing node pack error presentation. 4. #12768 simplified swap node error presentation. 5. This PR simplifies missing model presentation and the model-import handoff. ## Why This PR Is Larger Missing Models has more resolution paths than the previous error groups: - OSS can refresh model state, download individual models, and download all available models. - Cloud cannot download directly from the panel; it resolves supported rows through the model import dialog. - Some Cloud rows cannot be resolved through import at all because the node/widget cannot consume imported model assets. - Importing from Cloud needs to know the originating missing-model row so it can lock the expected model type and apply the imported model back to the affected widgets. - Already-imported files can still be unusable if they were imported under a different model type than the missing node expects. Because of those constraints, splitting the card layout from the dialog handoff would leave either a misleading Import button or an import dialog that does not know what it is resolving. This PR keeps that behavior in one reviewable unit. ## User-Facing Behavior ### Shared Missing Models Card - Replaces the older grouped presentation with compact model rows. - Shows each missing model as the primary row label. - Shows model metadata as a smaller sublabel instead of using large section headers. - Keeps locate-node controls visually consistent with the other simplified error cards. - Keeps rows expandable when multiple nodes reference the same missing model. - Shows the affected node rows under expanded models. - Allows single-reference rows to locate the affected node without rendering an extra duplicate child row. - Keeps unknown rows visible, including their affected nodes, instead of silently hiding them. - Removes the old library-select UI from the Missing Models card. ### OSS Behavior - Keeps the refresh action available from the Missing Models group header. - Keeps individual Download actions for downloadable models. - Moves file size out of the Download button label and into the row sublabel. - Keeps Download all when multiple downloadable models are available. - Places Download all at the bottom of the card rather than competing with the group header. - Leaves rows without a download URL as non-downloadable instead of rendering a broken action. ### Cloud Behavior - Shows Import only for missing models that can be resolved by importing a model asset of the required type. - Separates models that cannot be resolved through Cloud import into an Import Not Supported section. - Gives unsupported rows a direct explanation: nodes referencing those models do not support imported models, so users need to open the node and choose a supported built-in model or replace the node with a supported loader. - Treats unknown model type/directory as unsupported for Cloud import, because the import dialog cannot lock a valid model type and the node cannot safely consume the imported asset. - Keeps affected nodes visible in the unsupported section so users still have a path to locate and replace the node manually. ## Cloud Import Dialog Changes The shared model import dialog now accepts missing-model context when opened from the Missing Models card. When that context is present: - The dialog shows which missing model will be replaced. - The dialog lists the affected node/widget references that will be updated. - The model type selector is locked to the required model directory/type. - The Back/import-another path is disabled when it would break the targeted missing-model flow. - Import progress can be associated with the originating missing-model row. - After import completion, matching missing-model references are applied automatically where possible. - If the selected file is already imported under an incompatible model type, the dialog shows a targeted failure state explaining why this import cannot resolve the missing model. This keeps the generic import dialog reusable while adding only the context-specific behavior needed for Missing Models. ## Implementation Notes - `MissingModelCard.vue` owns the card-level grouping and OSS/Cloud section decisions. - `MissingModelRow.vue` owns per-model row rendering, expansion, locate actions, import/download actions, and row-level progress states. - `useMissingModelInteractions.ts` remains the interaction layer for locating nodes and applying resolved model selections. - `UploadModelDialog.vue`, `UploadModelConfirmation.vue`, `UploadModelFooter.vue`, `UploadModelProgress.vue`, and `useUploadModelWizard.ts` receive the missing-model context needed by the Cloud import handoff. - `MissingModelLibrarySelect.vue` is removed because the simplified card no longer exposes that inline selection path. - Locale and selector changes are limited to the new simplified row/section states and removed unused Missing Models strings. ## Tests Added / Updated - Unit coverage for Missing Models card grouping and row states. - Unit coverage for importable vs unsupported Cloud rows. - Unit coverage for model row expansion, locate actions, progress display, and action availability. - Unit coverage for upload confirmation/footer/progress behavior when a missing-model context is present. - Unit coverage for incompatible already-imported model handling. - E2E coverage for OSS Missing Models presentation. - E2E coverage for mode-aware Missing Models interactions. - Cloud E2E coverage for importable rows vs Import Not Supported rows. - Cloud E2E coverage for opening the import dialog with missing-model replacement context. ## Review Focus - Cloud import eligibility: unsupported or unknown model rows should not expose Import as if the row can be resolved automatically. - Missing-model context in the import dialog: the required model type should be locked, and the affected node/widget references should be clear. - OSS parity: OSS should keep refresh, individual Download, and Download all while visually matching the simplified Cloud card where possible. - Narrow side panel behavior: row labels may wrap, but link, primary action, and locate controls should not overlap. - Scope boundaries: this PR intentionally does not redesign Missing Node Pack / Swap Node / Missing Media again; visual parity issues shared across those cards can be handled in a follow-up unification pass if needed. ## Validation - `pnpm format` - `pnpm lint` - `pnpm typecheck` - Related unit tests: 8 files / 84 tests passed - `pnpm build` - OSS Missing Models E2E: `errorsTabMissingModels.spec.ts` passed, 8/8 - Mode-aware Missing Models E2E subset passed, 11/11, excluding unrelated local paste clipboard cases - `pnpm build:cloud` - Cloud Missing Models E2E: `errorsTabCloudMissingModels.spec.ts` passed, 3/3 - Final Claude review: no Blocker or Major findings ## Breaking / Dependencies - Breaking: none. - Dependencies: none. ## Screenshots OSS <img width="575" height="393" alt="스크린샷 2026-06-12 오전 12 25 27" src="https://github.com/user-attachments/assets/f5c44f95-711a-4d3d-99bd-f39ac2bb2012" /> <img width="659" height="351" alt="스크린샷 2026-06-12 오전 12 24 37" src="https://github.com/user-attachments/assets/4bb65a47-c1aa-408b-836b-a1998412f815" /> Cloud <img width="688" height="357" alt="스크린샷 2026-06-12 오전 12 23 59" src="https://github.com/user-attachments/assets/9330a7e7-9f22-420f-82b3-dde0fb2b3dd1" /> <img width="531" height="437" alt="스크린샷 2026-06-12 오전 12 21 13" src="https://github.com/user-attachments/assets/734bd911-f6f7-4872-8868-bb927ddeedd8" /> New import model flow https://github.com/user-attachments/assets/c094c670-62b9-47ce-bfe1-2d09f4f7359d
284 lines
8.9 KiB
TypeScript
284 lines
8.9 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
|
|
import { t } from '@/i18n'
|
|
// eslint-disable-next-line import-x/no-restricted-paths
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { app } from '@/scripts/app'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
|
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 isRefreshingMissingModels = ref(false)
|
|
|
|
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
|
|
// survives tab switches within the right-side panel.
|
|
const modelExpandState = ref<Record<string, boolean>>({})
|
|
const selectedLibraryModel = ref<Record<string, string>>({})
|
|
const importTaskIds = ref<Record<string, string>>({})
|
|
const folderPaths = ref<Record<string, string[]>>({})
|
|
const fileSizes = ref<Record<string, number>>({})
|
|
|
|
let _verificationAbortController: AbortController | null = null
|
|
|
|
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 removeMissingModelByWidget(nodeId: string, widgetName: string) {
|
|
if (!missingModelCandidates.value) return
|
|
missingModelCandidates.value = missingModelCandidates.value.filter(
|
|
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
|
|
)
|
|
if (!missingModelCandidates.value.length)
|
|
missingModelCandidates.value = null
|
|
}
|
|
|
|
function clearInteractionStateForName(name: string) {
|
|
delete modelExpandState.value[name]
|
|
delete selectedLibraryModel.value[name]
|
|
delete importTaskIds.value[name]
|
|
}
|
|
|
|
function removeMissingModelsByNodeId(nodeId: string) {
|
|
if (!missingModelCandidates.value) return
|
|
const removedNames = new Set(
|
|
missingModelCandidates.value
|
|
.filter((m) => String(m.nodeId) === nodeId)
|
|
.map((m) => m.name)
|
|
)
|
|
missingModelCandidates.value = missingModelCandidates.value.filter(
|
|
(m) => String(m.nodeId) !== nodeId
|
|
)
|
|
for (const name of removedNames) {
|
|
if (!missingModelCandidates.value.some((m) => m.name === name)) {
|
|
clearInteractionStateForName(name)
|
|
}
|
|
}
|
|
if (!missingModelCandidates.value.length)
|
|
missingModelCandidates.value = null
|
|
}
|
|
|
|
/**
|
|
* Remove all candidates whose nodeId starts with `prefix`.
|
|
*
|
|
* Intended for clearing all interior errors when a subgraph container is
|
|
* removed. Callers are expected to pass `${execId}:` (with trailing
|
|
* colon) so that sibling IDs sharing a numeric prefix (e.g. `"705"` vs
|
|
* `"70"`) are not matched.
|
|
*/
|
|
function removeMissingModelsByPrefix(prefix: string) {
|
|
if (!missingModelCandidates.value) return
|
|
const removedNames = new Set<string>()
|
|
const remaining: MissingModelCandidate[] = []
|
|
for (const m of missingModelCandidates.value) {
|
|
// Preserve workflow-level candidates with no nodeId; they are not
|
|
// tied to any subgraph scope and should never be matched by prefix.
|
|
if (m.nodeId == null) {
|
|
remaining.push(m)
|
|
continue
|
|
}
|
|
if (String(m.nodeId).startsWith(prefix)) {
|
|
removedNames.add(m.name)
|
|
} else {
|
|
remaining.push(m)
|
|
}
|
|
}
|
|
if (removedNames.size === 0) return
|
|
missingModelCandidates.value = remaining.length ? remaining : null
|
|
for (const name of removedNames) {
|
|
if (!remaining.some((m) => m.name === name)) {
|
|
clearInteractionStateForName(name)
|
|
}
|
|
}
|
|
}
|
|
|
|
function addMissingModels(models: MissingModelCandidate[]) {
|
|
if (!models.length) return
|
|
const existing = missingModelCandidates.value ?? []
|
|
const existingKeys = new Set(
|
|
existing.map((m) => `${String(m.nodeId)}::${m.widgetName}::${m.name}`)
|
|
)
|
|
const newModels = models.filter(
|
|
(m) =>
|
|
!existingKeys.has(`${String(m.nodeId)}::${m.widgetName}::${m.name}`)
|
|
)
|
|
if (!newModels.length) return
|
|
missingModelCandidates.value = [...existing, ...newModels]
|
|
}
|
|
|
|
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 setFolderPaths(paths: Record<string, string[]>) {
|
|
folderPaths.value = paths
|
|
}
|
|
|
|
function setFileSize(url: string, size: number) {
|
|
fileSizes.value[url] = size
|
|
}
|
|
|
|
function clearMissingModels() {
|
|
_verificationAbortController?.abort()
|
|
_verificationAbortController = null
|
|
missingModelCandidates.value = null
|
|
modelExpandState.value = {}
|
|
selectedLibraryModel.value = {}
|
|
importTaskIds.value = {}
|
|
folderPaths.value = {}
|
|
fileSizes.value = {}
|
|
}
|
|
|
|
function isAbortError(error: unknown) {
|
|
return error instanceof Error && error.name === 'AbortError'
|
|
}
|
|
|
|
async function refreshMissingModels() {
|
|
if (isRefreshingMissingModels.value) return
|
|
|
|
isRefreshingMissingModels.value = true
|
|
try {
|
|
await app.refreshMissingModels({ silent: true })
|
|
} catch (error) {
|
|
if (isAbortError(error)) return
|
|
|
|
console.error('Failed to refresh missing models:', error)
|
|
useToastStore().add({
|
|
severity: 'error',
|
|
summary: t('g.error'),
|
|
detail: t('rightSidePanel.missingModels.refreshFailed')
|
|
})
|
|
} finally {
|
|
isRefreshingMissingModels.value = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
missingModelCandidates,
|
|
isRefreshingMissingModels,
|
|
hasMissingModels,
|
|
missingModelCount,
|
|
missingModelNodeIds,
|
|
activeMissingModelGraphIds,
|
|
missingModelAncestorExecutionIds,
|
|
|
|
setMissingModels,
|
|
addMissingModels,
|
|
removeMissingModelByNameOnNodes,
|
|
removeMissingModelByWidget,
|
|
removeMissingModelsByNodeId,
|
|
removeMissingModelsByPrefix,
|
|
clearMissingModels,
|
|
refreshMissingModels,
|
|
createVerificationAbortController,
|
|
|
|
hasMissingModelOnNode,
|
|
isWidgetMissingModel,
|
|
isContainerWithMissingModel,
|
|
|
|
modelExpandState,
|
|
selectedLibraryModel,
|
|
importTaskIds,
|
|
folderPaths,
|
|
fileSizes,
|
|
|
|
setFolderPaths,
|
|
setFileSize
|
|
}
|
|
})
|