Files
ComfyUI_frontend/src/platform/missingModel/missingModelStore.ts
2026-05-03 03:41:15 -07:00

345 lines
11 KiB
TypeScript

import { defineStore } from 'pinia'
import { computed, onScopeDispose, 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,
MissingModelDownloadRef
} from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
/**
* Missing model error state and interaction state.
* Separated from executionErrorStore to keep domain boundaries clean.
* The executionErrorStore composes from this store for aggregate error flags.
*/
export const useMissingModelStore = defineStore('missingModel', () => {
const canvasStore = useCanvasStore()
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
const 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,
// URL inputs, etc. survive tab switches within the right-side panel.
const modelExpandState = ref<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const downloadRefs = ref<Record<string, MissingModelDownloadRef>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
const urlErrors = ref<Record<string, string>>({})
const urlImporting = ref<Record<string, boolean>>({})
const folderPaths = ref<Record<string, string[]>>({})
const fileSizes = ref<Record<string, number>>({})
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
let _verificationAbortController: AbortController | null = null
onScopeDispose(cancelDebounceTimers)
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
return _verificationAbortController
}
function setMissingModels(models: MissingModelCandidate[]) {
missingModelCandidates.value = models.length ? models : null
}
function removeMissingModelByNameOnNodes(
modelName: string,
nodeIds: Set<string>
) {
if (!missingModelCandidates.value) return
missingModelCandidates.value = missingModelCandidates.value.filter(
(m) =>
m.name !== modelName ||
m.nodeId == null ||
!nodeIds.has(String(m.nodeId))
)
if (!missingModelCandidates.value.length)
missingModelCandidates.value = null
}
function 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 importCategoryMismatch.value[name]
delete downloadRefs.value[name]
delete urlInputs.value[name]
delete urlMetadata.value[name]
delete urlFetching.value[name]
delete urlErrors.value[name]
delete urlImporting.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 cancelDebounceTimers() {
for (const key of Object.keys(_urlDebounceTimers)) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setDebounceTimer(
key: string,
callback: () => void,
delayMs: number
) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
}
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
}
function clearDebounceTimer(key: string) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function 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
cancelDebounceTimers()
modelExpandState.value = {}
selectedLibraryModel.value = {}
importCategoryMismatch.value = {}
downloadRefs.value = {}
urlInputs.value = {}
urlMetadata.value = {}
urlFetching.value = {}
urlErrors.value = {}
urlImporting.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,
downloadRefs,
importCategoryMismatch,
urlInputs,
urlMetadata,
urlFetching,
urlErrors,
urlImporting,
folderPaths,
fileSizes,
setFolderPaths,
setFileSize,
setDebounceTimer,
clearDebounceTimer
}
})