mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-02 20:22:08 +00:00
## Summary When a workflow is loaded with missing models, users currently have no way to identify or resolve them from within the UI. This PR adds a full missing-model detection and resolution pipeline that surfaces missing models in the Errors tab, allowing users to install or import them without leaving the editor. ## Changes ### Missing Model Detection - Scan all COMBO widgets across root graph and subgraphs for model-like filenames during workflow load - Enrich candidates with embedded workflow metadata (url, hash, directory) when available - Verify asset-supported candidates against the asset store asynchronously to confirm installation status - Propagate missing model state to `executionErrorStore` alongside existing node/prompt errors ### Errors Tab UI — Model Resolution - Group missing models by directory (e.g. `checkpoints`, `loras`, `vae`) with collapsible category cards - Each model row displays: - Model name with copy-to-clipboard button - Expandable list of referencing nodes with locate-on-canvas button - **Library selector**: Pick an alternative from the user's existing models to substitute the missing model with one click - **URL import**: Paste a Civitai or HuggingFace URL to import a model directly; debounced metadata fetch shows filename and file size before confirming; type-mismatch warnings (e.g. importing a LoRA into checkpoints directory) are surfaced with an "Import Anyway" option - **Upgrade prompt**: In cloud environment, free-tier subscribers are shown an upgrade modal when attempting URL import - Separate "Import Not Supported" section for custom-node models that cannot be auto-resolved - Status card with live download progress, completion, failure, and category-mismatch states ### Canvas Integration - Highlight nodes and widgets that reference missing models with error indicators - Propagate missing-model badges through subgraph containers so issues are visible at every graph level ### Code Cleanup - Simplify `surfacePendingWarnings` in workflowService, remove stale widget-detected model merging logic - Add `flattenWorkflowNodes` utility to workflowSchema for traversing nested subgraph structures - Extract `MissingModelUrlInput`, `MissingModelLibrarySelect`, `MissingModelStatusCard` as focused single-responsibility components ## Testing - Unit tests for scan pipeline (`missingModelScan.test.ts`): enrichment, skip-installed, subgraph flattening - Unit tests for store (`missingModelStore.test.ts`): state management, removal helpers - Unit tests for interactions (`useMissingModelInteractions.test.ts`): combo select, URL input, import flow, library confirm - Component tests for `MissingModelCard` and error grouping (`useErrorGroups.test.ts`) - Updated `workflowService.test.ts` and `workflowSchema.test.ts` for new logic ## Review Focus - Missing model scan + enrichment pipeline in `missingModelScan.ts` - Interaction composable `useMissingModelInteractions.ts` — URL metadata fetch, library install, upload fallback - Store integration and canvas-level error propagation ## Screenshots https://github.com/user-attachments/assets/339a6d5b-93a3-43cd-98dd-0fb00681b66f ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9743-feat-Surface-missing-models-in-Errors-tab-Cloud-3206d73d365081678326d3a16c2165d8) by [Unito](https://www.unito.io)
114 lines
3.2 KiB
Vue
114 lines
3.2 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-2">
|
|
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
|
|
<span class="text-xs font-bold text-muted-foreground">
|
|
{{ t('rightSidePanel.missingModels.or') }}
|
|
</span>
|
|
</div>
|
|
|
|
<Select
|
|
:model-value="modelValue"
|
|
:disabled="options.length === 0"
|
|
@update:model-value="handleSelect"
|
|
>
|
|
<SelectTrigger
|
|
size="md"
|
|
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
|
|
>
|
|
<SelectValue
|
|
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
|
|
/>
|
|
</SelectTrigger>
|
|
|
|
<SelectContent>
|
|
<template v-if="options.length > 4" #prepend>
|
|
<div class="px-1 pb-1.5">
|
|
<div
|
|
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
|
|
>
|
|
<i
|
|
aria-hidden="true"
|
|
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
|
|
/>
|
|
<input
|
|
v-model="filterQuery"
|
|
type="text"
|
|
:aria-label="t('g.searchPlaceholder', { subject: '' })"
|
|
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
|
:placeholder="t('g.searchPlaceholder', { subject: '' })"
|
|
@keydown.stop
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<SelectItem
|
|
v-for="option in filteredOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
class="text-xs"
|
|
>
|
|
{{ option.name }}
|
|
</SelectItem>
|
|
<div
|
|
v-if="filteredOptions.length === 0"
|
|
role="status"
|
|
class="px-3 py-2 text-xs text-muted-foreground"
|
|
>
|
|
{{ t('g.noResultsFound') }}
|
|
</div>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useFuse } from '@vueuse/integrations/useFuse'
|
|
import Select from '@/components/ui/select/Select.vue'
|
|
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
|
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
|
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
|
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
|
|
|
const { options, showDivider = false } = defineProps<{
|
|
modelValue: string | undefined
|
|
options: { name: string; value: string }[]
|
|
showDivider?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [value: string]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const filterQuery = ref('')
|
|
|
|
watch(
|
|
() => options.length,
|
|
(len) => {
|
|
if (len <= 4) filterQuery.value = ''
|
|
}
|
|
)
|
|
|
|
const { results: fuseResults } = useFuse(filterQuery, () => options, {
|
|
fuseOptions: {
|
|
keys: ['name'],
|
|
threshold: 0.4,
|
|
ignoreLocation: true
|
|
},
|
|
matchAllWhenSearchEmpty: true
|
|
})
|
|
|
|
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
|
|
|
|
function handleSelect(value: unknown) {
|
|
if (typeof value === 'string') {
|
|
filterQuery.value = ''
|
|
emit('select', value)
|
|
}
|
|
}
|
|
</script>
|