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(null) const isRefreshingMissingModels = ref(false) const hasMissingModels = computed( () => !!missingModelCandidates.value?.length ) const missingModelCount = computed( () => missingModelCandidates.value?.length ?? 0 ) const missingModelNodeIds = computed>(() => { const ids = new Set() 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>(() => { const keys = new Set() 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>( () => { const ids = new Set() for (const nodeId of missingModelNodeIds.value) { for (const id of getAncestorExecutionIds(nodeId)) { ids.add(id) } } return ids } ) const activeMissingModelGraphIds = computed>(() => { 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>({}) const selectedLibraryModel = ref>({}) const importCategoryMismatch = ref>({}) const downloadRefs = ref>({}) const urlInputs = ref>({}) const urlMetadata = ref>({}) const urlFetching = ref>({}) const urlErrors = ref>({}) const urlImporting = ref>({}) const folderPaths = ref>({}) const fileSizes = ref>({}) const _urlDebounceTimers: Record> = {} 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 ) { 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() 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) { 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 } })