Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/components/rightSidePanel/RightSidePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
Expand All @@ -36,13 +37,16 @@ import TabErrors from './errors/TabErrors.vue'

const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()

const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)

const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)

const { findParentGroup } = useGraphHierarchy()

const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
Expand Down Expand Up @@ -118,12 +122,21 @@ const hasMissingNodeSelected = computed(
)
)

const hasMissingModelSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingModelGraphIds.value.has(String(node.id))
)
)

const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value
hasMissingNodeSelected.value ||
hasMissingModelSelected.value
)
})

Expand Down Expand Up @@ -314,7 +327,11 @@ function handleTitleCancel() {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
<i
v-if="tab.icon"
aria-hidden="true"
:class="cn(tab.icon, 'size-4')"
/>
</Tab>
</TabList>
</nav>
Expand Down
20 changes: 18 additions & 2 deletions src/components/rightSidePanel/errors/TabErrors.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</div>

<!-- Scrollable content -->
<div class="min-w-0 flex-1 overflow-y-auto">
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
Expand All @@ -33,7 +33,9 @@
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
group.type === 'missing_node' ||
group.type === 'swap_nodes' ||
group.type === 'missing_model'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nit: can we replace it to computed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Extracted to a getGroupSize() function using a Set for the full-size group types. Chose a plain function over computed since it takes a parameter (the group) and the Set lookup is O(1).

? 'lg'
: 'default'
"
Expand Down Expand Up @@ -130,6 +132,14 @@
@copy-to-clipboard="copyToClipboard"
/>
</div>

<!-- Missing Models -->
<MissingModelCard
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
/>
</PropertiesAccordionItem>
</TransitionGroup>
</div>
Expand Down Expand Up @@ -187,6 +197,7 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
Expand Down Expand Up @@ -226,6 +237,7 @@ const {
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)

Expand Down Expand Up @@ -283,6 +295,10 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}

function handleLocateModel(nodeId: string) {
focusNode(nodeId)
}

function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
Expand Down
1 change: 1 addition & 0 deletions src/components/rightSidePanel/errors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export type ErrorGroup =
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }
118 changes: 118 additions & 0 deletions src/components/rightSidePanel/errors/useErrorGroups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))

vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
clearMissingModelState: vi.fn()
})
)

import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'

Expand Down Expand Up @@ -520,4 +527,115 @@ describe('useErrorGroups', () => {
expect(typeof groups.collapseState).toBe('object')
})
})

describe('missingModelGroups', () => {
function makeModel(
name: string,
opts: {
nodeId?: string | number
widgetName?: string
directory?: string
isAssetSupported?: boolean
} = {}
) {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true as const,
directory: opts.directory
}
}

it('returns empty array when no missing models', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups.value).toEqual([])
})

it('groups asset-supported models by directory', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('lora_a.safetensors', {
nodeId: '3',
directory: 'loras',
isAssetSupported: true
})
])
await nextTick()

expect(groups.missingModelGroups.value).toHaveLength(2)
const ckptGroup = groups.missingModelGroups.value.find(
(g) => g.directory === 'checkpoints'
)
expect(ckptGroup?.models).toHaveLength(2)
expect(ckptGroup?.isAssetSupported).toBe(true)
})

it('puts unsupported models in a separate group', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('custom_model.safetensors', {
nodeId: '2',
isAssetSupported: false
})
])
await nextTick()

expect(groups.missingModelGroups.value).toHaveLength(2)
const unsupported = groups.missingModelGroups.value.find(
(g) => !g.isAssetSupported
)
expect(unsupported?.models).toHaveLength(1)
})

it('merges same-named models into one view model with multiple referencingNodes', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('shared_model.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('shared_model.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
})
])
await nextTick()

expect(groups.missingModelGroups.value).toHaveLength(1)
const model = groups.missingModelGroups.value[0].models[0]
expect(model.name).toBe('shared_model.safetensors')
expect(model.referencingNodes).toHaveLength(2)
})

it('includes missing_model group in allErrorGroups', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([makeModel('model_a.safetensors')])
await nextTick()

const modelGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_model'
)
expect(modelGroup).toBeDefined()
})
})
})
77 changes: 75 additions & 2 deletions src/components/rightSidePanel/errors/useErrorGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'

import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
Expand All @@ -23,6 +24,11 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import {
isNodeExecutionId,
compareExecutionId
Expand All @@ -39,6 +45,9 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'

/** Sentinel key for grouping non-asset-supported missing models. */
const UNSUPPORTED = Symbol('unsupported')

export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
Expand Down Expand Up @@ -231,6 +240,7 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
Expand Down Expand Up @@ -559,14 +569,72 @@ export function useErrorGroups(
return groups.sort((a, b) => a.priority - b.priority)
}

/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
* Within each group, candidates with the same model name are merged into a single view model. */
const missingModelGroups = computed<MissingModelGroup[]>(() => {
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []

type GroupKey = string | null | typeof UNSUPPORTED
const map = new Map<
GroupKey,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()

for (const c of candidates) {
const groupKey: GroupKey = c.isAssetSupported
? c.directory || null
: UNSUPPORTED

const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}

return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
})

function buildMissingModelGroups(): ErrorGroup[] {
if (!missingModelGroups.value.length) return []
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelStore.missingModelCandidates?.length ?? 0})`,
priority: 2
}
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()

processPromptError(groupsMap)
processNodeErrors(groupsMap)
processExecutionError(groupsMap)

return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...toSortedGroups(groupsMap)
]
})

const tabErrorGroups = computed<ErrorGroup[]>(() => {
Expand All @@ -580,7 +648,11 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)

return [...buildMissingNodeGroups(), ...executionGroups]
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...executionGroups
]
})

const filteredGroups = computed<ErrorGroup[]>(() => {
Expand Down Expand Up @@ -615,6 +687,7 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
swapNodeGroups
}
}
Loading
Loading