mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
## Summary Follow-up to the closed earlier attempt in #11646. This PR keeps the same user-facing goal, but changes the implementation to reuse the existing missing model pipeline for refresh instead of maintaining a separate candidate-only recheck path. Adds a missing model refresh action in the Errors tab by reusing the existing missing model pipeline, so users can re-check models after downloading or manually placing files without reloading the workflow. ## Changes - **What**: - Adds `app.refreshMissingModels()` as a reusable refresh entry point for the current root graph. - Splits node definition reloading into `app.reloadNodeDefs()` so missing-model refresh can pull fresh `object_info` without showing the generic combo refresh success flow. - Reuses the existing missing model pipeline instead of adding a separate candidate-only checker. The refresh path serializes the current graph, reuses active workflow model metadata when available, falls back to current missing-model metadata, and then reruns the same candidate discovery/enrichment/surfacing flow used during workflow load. - Adds missing model refresh state and error handling to `missingModelStore`. - Adds a Refresh button next to Download all in the missing model card action bar. - Moves Download all from the Errors tab header into the missing model card, so the Download all and Refresh actions render or hide together. - Changes Download all visibility from “more than one downloadable model” to “at least one downloadable model.” - Keeps the action bar hidden when there are no downloadable missing models; Cloud still does not render this action area. - Normalizes active workflow `pendingWarnings` updates so resolved missing model warnings do not get revived by stale empty warning objects. - Adds test IDs and coverage for the new action bar, refresh state, refresh delegation, pending warning sync, and E2E refresh behavior. - **Breaking**: None. - **Dependencies**: None. ## Review Focus The main design choice is intentionally reusing the missing model pipeline for refresh instead of implementing a smaller candidate-only recheck. The earlier candidate-only approach was cheaper, but it created a separate source of truth for missing-model resolution and made edge cases harder to reason about. In particular, it could diverge from the behavior used when a workflow is loaded, and it did not naturally handle the case where a model becomes missing after the workflow is already open. This version pays the cost of refreshing node definitions and rerunning the missing-model scan for the current graph, but keeps the refresh behavior aligned with workflow load semantics. Expected behavior by environment: - OSS browser: - The action bar appears when at least one missing model has a downloadable URL and directory. - Download all uses the existing browser download path. - Refresh reloads `object_info`, refreshes node definitions/combo values, reruns missing-model detection for the current graph, and clears the error if the selected model is now available. - OSS desktop: - The same action bar appears under the same downloadable-model condition. - Download all uses the existing Electron DownloadManager path. - Refresh uses the same missing-model pipeline as browser, so manually placed files or desktop-downloaded files can be rechecked without reloading the workflow. - Cloud: - The action bar remains hidden because model download/import is not supported in this section for Cloud. A few boundaries are intentional: - This PR does not add automatic filesystem watching. Browser OSS cannot reliably observe local model folder changes, so the user-triggered Refresh button remains the cross-environment mechanism. - This PR does not redesign the public `refreshComboInNodes` API beyond extracting `reloadNodeDefs()` for reuse. Further cleanup of toast behavior or a more explicit object-info reload API can be follow-up work. - This PR keeps refresh scoped to missing-model validation; missing media and missing nodes continue to use their existing flows. Linear: FE-417 ## Screenshots (if applicable) https://github.com/user-attachments/assets/2e02799f-1374-4377-b7b3-172241517772 ## Validation - `pnpm format` - `pnpm lint` (passes; existing unrelated warning remains in `src/platform/workspace/composables/useWorkspaceBilling.test.ts`) - `pnpm typecheck` - `pnpm test:unit` - `pnpm test:browser:local -- --project=chromium browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts` - `pnpm build` - `NX_SKIP_NX_CACHE=true DISTRIBUTION=desktop USE_PROD_CONFIG=true NODE_OPTIONS='--max-old-space-size=8192' pnpm exec nx build` - Manual desktop verification through `~/Projects/desktop` after copying the desktop build into `assets/ComfyUI/web_custom_versions/desktop_app`: - confirmed the FE bundle is built with `DISTRIBUTION = "desktop"` - confirmed missing model Download uses the desktop download path instead of browser download - confirmed Refresh can clear the missing model error after the model is available - Push hook: `pnpm knip --cache` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11661-feat-refresh-missing-models-through-pipeline-34f6d73d3650811488defee54a7a6667) by [Unito](https://www.unito.io)
446 lines
15 KiB
Vue
446 lines
15 KiB
Vue
<template>
|
|
<div class="flex h-full min-w-0 flex-col">
|
|
<!-- Search bar + collapse toggle -->
|
|
<div
|
|
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
|
>
|
|
<FormSearchInput v-model="searchQuery" class="flex-1" />
|
|
<CollapseToggleButton
|
|
v-model="isAllCollapsed"
|
|
:show="!isSearching && tabErrorGroups.length > 1"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Runtime error: full-height panel outside accordion -->
|
|
<div
|
|
v-if="singleRuntimeErrorCard"
|
|
data-testid="runtime-error-panel"
|
|
aria-live="polite"
|
|
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
|
>
|
|
<div
|
|
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
|
|
>
|
|
{{ singleRuntimeErrorGroup?.title }}
|
|
</div>
|
|
<ErrorNodeCard
|
|
:key="singleRuntimeErrorCard.id"
|
|
:card="singleRuntimeErrorCard"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
full-height
|
|
class="min-h-0 flex-1"
|
|
@locate-node="handleLocateNode"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
@copy-to-clipboard="copyToClipboard"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Scrollable content (non-runtime or mixed errors) -->
|
|
<div v-else 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"
|
|
key="empty"
|
|
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
|
>
|
|
{{
|
|
searchQuery.trim()
|
|
? t('rightSidePanel.noneSearchDesc')
|
|
: t('rightSidePanel.noErrors')
|
|
}}
|
|
</div>
|
|
|
|
<!-- Group by Class Type -->
|
|
<PropertiesAccordionItem
|
|
v-for="group in filteredGroups"
|
|
:key="group.title"
|
|
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
|
:collapse="isSectionCollapsed(group.title) && !isSearching"
|
|
class="border-b border-interface-stroke"
|
|
:size="getGroupSize(group)"
|
|
@update:collapse="setSectionCollapsed(group.title, $event)"
|
|
>
|
|
<template #label>
|
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
<span class="flex min-w-0 flex-1 items-center gap-2">
|
|
<i
|
|
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
|
/>
|
|
<span class="truncate text-destructive-background-hover">
|
|
{{
|
|
group.type === 'missing_node'
|
|
? `${group.title} (${missingPackGroups.length})`
|
|
: group.type === 'swap_nodes'
|
|
? `${group.title} (${swapNodeGroups.length})`
|
|
: group.title
|
|
}}
|
|
</span>
|
|
<span
|
|
v-if="group.type === 'execution' && group.cards.length > 1"
|
|
class="text-destructive-background-hover"
|
|
>
|
|
({{ group.cards.length }})
|
|
</span>
|
|
</span>
|
|
<Button
|
|
v-if="
|
|
group.type === 'missing_node' &&
|
|
missingNodePacks.length > 0 &&
|
|
shouldShowInstallButton
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
:disabled="isInstallingAll"
|
|
@click.stop="installAll"
|
|
>
|
|
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
|
{{
|
|
isInstallingAll
|
|
? t('rightSidePanel.missingNodePacks.installing')
|
|
: t('rightSidePanel.missingNodePacks.installAll')
|
|
}}
|
|
</Button>
|
|
<Button
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
v-tooltip.top="
|
|
t(
|
|
'nodeReplacement.replaceAllWarning',
|
|
'Replaces all available nodes in this group.'
|
|
)
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
@click.stop="handleReplaceAll()"
|
|
>
|
|
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
|
</Button>
|
|
<Button
|
|
v-else-if="
|
|
group.type === 'missing_model' &&
|
|
showMissingModelHeaderRefresh
|
|
"
|
|
data-testid="missing-model-header-refresh"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
|
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
|
@click.stop="handleMissingModelRefresh"
|
|
>
|
|
<DotSpinner
|
|
v-if="missingModelStore.isRefreshingMissingModels"
|
|
aria-hidden="true"
|
|
duration="1s"
|
|
:size="12"
|
|
/>
|
|
<i
|
|
v-else
|
|
aria-hidden="true"
|
|
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
|
/>
|
|
{{ t('rightSidePanel.missingModels.refresh') }}
|
|
</Button>
|
|
<span
|
|
v-if="
|
|
group.type === 'missing_model' &&
|
|
showMissingModelHeaderRefresh
|
|
"
|
|
role="status"
|
|
aria-live="polite"
|
|
class="sr-only"
|
|
>
|
|
{{
|
|
missingModelStore.isRefreshingMissingModels
|
|
? t('rightSidePanel.missingModels.refreshing')
|
|
: ''
|
|
}}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Missing Node Packs -->
|
|
<MissingNodeCard
|
|
v-if="group.type === 'missing_node'"
|
|
:show-info-button="shouldShowManagerButtons"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:missing-pack-groups="missingPackGroups"
|
|
@locate-node="handleLocateMissingNode"
|
|
@open-manager-info="handleOpenManagerInfo"
|
|
/>
|
|
|
|
<!-- Swap Nodes -->
|
|
<SwapNodesCard
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
:swap-node-groups="swapNodeGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-node="handleLocateMissingNode"
|
|
@replace="handleReplaceGroup"
|
|
/>
|
|
|
|
<!-- Execution Errors -->
|
|
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
|
<ErrorNodeCard
|
|
v-for="card in group.cards"
|
|
:key="card.id"
|
|
:card="card"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:compact="isSingleNodeSelected"
|
|
@locate-node="handleLocateNode"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
@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="handleLocateAssetNode"
|
|
/>
|
|
|
|
<!-- Missing Media -->
|
|
<MissingMediaCard
|
|
v-else-if="group.type === 'missing_media'"
|
|
:missing-media-groups="missingMediaGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-node="handleLocateAssetNode"
|
|
/>
|
|
</PropertiesAccordionItem>
|
|
</TransitionGroup>
|
|
</div>
|
|
|
|
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
|
|
|
<!-- Fixed Footer: Help Links -->
|
|
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
|
<i18n-t
|
|
keypath="rightSidePanel.errorHelp"
|
|
tag="p"
|
|
class="m-0 text-sm/tight wrap-break-word text-muted-foreground"
|
|
>
|
|
<template #github>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline text-sm whitespace-nowrap text-inherit underline"
|
|
@click="openGitHubIssues"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpGithub') }}
|
|
</Button>
|
|
</template>
|
|
<template #support>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline text-sm whitespace-nowrap text-inherit underline"
|
|
@click="contactSupport"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpSupport') }}
|
|
</Button>
|
|
</template>
|
|
</i18n-t>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
|
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
import { NodeBadgeMode } from '@/types/nodeSource'
|
|
|
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
|
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
|
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
|
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 MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
|
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
|
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
|
import { useErrorActions } from './useErrorActions'
|
|
import { useErrorGroups } from './useErrorGroups'
|
|
import type { SwapNodeGroup } from './useErrorGroups'
|
|
import type { ErrorGroup } from './types'
|
|
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
|
|
|
const ErrorPanelSurveyCta =
|
|
isNightly && !isCloud && !isDesktop
|
|
? defineAsyncComponent(
|
|
() => import('@/platform/surveys/ErrorPanelSurveyCta.vue')
|
|
)
|
|
: undefined
|
|
|
|
const { t } = useI18n()
|
|
const { copyToClipboard } = useCopyToClipboard()
|
|
const { focusNode, enterSubgraph } = useFocusNode()
|
|
const { openGitHubIssues, contactSupport } = useErrorActions()
|
|
const settingStore = useSettingStore()
|
|
const rightSidePanelStore = useRightSidePanelStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
|
useManagerState()
|
|
const { missingNodePacks } = useMissingNodes()
|
|
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
|
usePackInstall(() => missingNodePacks.value)
|
|
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
|
|
|
const searchQuery = ref('')
|
|
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
|
|
|
const fullSizeGroupTypes = new Set([
|
|
'missing_node',
|
|
'swap_nodes',
|
|
'missing_model',
|
|
'missing_media'
|
|
])
|
|
function getGroupSize(group: ErrorGroup) {
|
|
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
|
}
|
|
|
|
const showNodeIdBadge = computed(
|
|
() =>
|
|
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
|
NodeBadgeMode.None
|
|
)
|
|
|
|
const {
|
|
allErrorGroups,
|
|
tabErrorGroups,
|
|
filteredGroups,
|
|
collapseState,
|
|
isSingleNodeSelected,
|
|
errorNodeCache,
|
|
missingNodeCache,
|
|
missingPackGroups,
|
|
filteredMissingModelGroups: missingModelGroups,
|
|
filteredMissingMediaGroups: missingMediaGroups,
|
|
swapNodeGroups
|
|
} = useErrorGroups(searchQuery, t)
|
|
|
|
const missingModelDownloadableModels = computed(() => {
|
|
if (isCloud) return []
|
|
|
|
return getDownloadableModels(missingModelGroups.value)
|
|
})
|
|
|
|
const showMissingModelHeaderRefresh = computed(
|
|
() =>
|
|
!isCloud &&
|
|
missingModelGroups.value.length > 0 &&
|
|
missingModelDownloadableModels.value.length === 0
|
|
)
|
|
|
|
function handleMissingModelRefresh() {
|
|
void missingModelStore.refreshMissingModels()
|
|
}
|
|
|
|
const singleRuntimeErrorGroup = computed(() => {
|
|
if (filteredGroups.value.length !== 1) return null
|
|
const group = filteredGroups.value[0]
|
|
const isSoleRuntimeError =
|
|
group.type === 'execution' &&
|
|
group.cards.length === 1 &&
|
|
group.cards[0].errors.every((e) => e.isRuntimeError)
|
|
return isSoleRuntimeError ? group : null
|
|
})
|
|
|
|
const singleRuntimeErrorCard = computed(
|
|
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
|
)
|
|
|
|
const isAllCollapsed = computed({
|
|
get() {
|
|
return filteredGroups.value.every((g) => isSectionCollapsed(g.title))
|
|
},
|
|
set(collapse: boolean) {
|
|
for (const group of tabErrorGroups.value) {
|
|
setSectionCollapsed(group.title, collapse)
|
|
}
|
|
}
|
|
})
|
|
|
|
function isSectionCollapsed(title: string): boolean {
|
|
// Defaults to expanded when not explicitly set by the user
|
|
return collapseState[title] ?? false
|
|
}
|
|
|
|
function setSectionCollapsed(title: string, collapsed: boolean) {
|
|
collapseState[title] = collapsed
|
|
}
|
|
|
|
/**
|
|
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
|
* sets focusedErrorNodeId, expand only the group containing the target
|
|
* node and collapse all others so the user sees the relevant errors
|
|
* immediately.
|
|
*/
|
|
watch(
|
|
() => rightSidePanelStore.focusedErrorNodeId,
|
|
(graphNodeId) => {
|
|
if (!graphNodeId) return
|
|
const prefix = `${graphNodeId}:`
|
|
for (const group of allErrorGroups.value) {
|
|
if (group.type !== 'execution') continue
|
|
|
|
const hasMatch = group.cards.some(
|
|
(card) =>
|
|
card.graphNodeId === graphNodeId ||
|
|
(card.nodeId?.startsWith(prefix) ?? false)
|
|
)
|
|
setSectionCollapsed(group.title, !hasMatch)
|
|
}
|
|
rightSidePanelStore.focusedErrorNodeId = null
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function handleLocateNode(nodeId: string) {
|
|
focusNode(nodeId, errorNodeCache.value)
|
|
}
|
|
|
|
function handleLocateMissingNode(nodeId: string) {
|
|
focusNode(nodeId, missingNodeCache.value)
|
|
}
|
|
|
|
function handleLocateAssetNode(nodeId: string) {
|
|
focusNode(nodeId)
|
|
}
|
|
|
|
function handleOpenManagerInfo(packId: string) {
|
|
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
|
if (isKnownToRegistry) {
|
|
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
|
} else {
|
|
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
|
}
|
|
}
|
|
|
|
function handleReplaceGroup(group: SwapNodeGroup) {
|
|
replaceGroup(group)
|
|
}
|
|
|
|
function handleReplaceAll() {
|
|
replaceAllGroups(swapNodeGroups.value)
|
|
}
|
|
|
|
function handleEnterSubgraph(nodeId: string) {
|
|
enterSubgraph(nodeId, errorNodeCache.value)
|
|
}
|
|
</script>
|