Files
ComfyUI_frontend/src/stores/modelStore.ts
Dante d955625c20 fix(model-library): auto-refresh after upload, plus 'r' key fallback (FE-695) (#12257)
## Summary

Model Library sidebar now refreshes automatically when an upload
completes, and the `r` keybinding refreshes the library in addition to
refreshing combo widgets inside graph nodes.

## Changes

- **What**:
- `modelStore`: new `refreshModelFolder(name)` for surgical reset+reload
of one folder, and `refresh()` that re-loads any folders that had been
loaded
- `ModelLibrarySidebarTab.vue`: watches
`assetDownloadStore.lastCompletedDownload` and refreshes the affected
folder; the in-panel refresh button now routes through `refresh()`
- `Comfy.RefreshNodeDefinitions` (`r` key): also calls
`modelStore.refresh()` so the keyboard fallback actually refreshes the
Model Library list

## Review Focus

- Both `modelStore` and `assetsStore` exist; the upload wizard was only
refreshing the latter, which is what caused the bug. Confirm the new
watcher path is the right hook (rather than wiring it inside the wizard)
— chose this so it also covers completions that happen after the wizard
has been closed.
- `refreshModelFolder` falls back to `refresh()` (not raw
`loadModelFolders()`) for unknown folder types, to avoid dropping other
folders' loaded contents.
- Generated tab half of the ticket is intentionally **deferred** until
BE-885 (cursor pagination on `GET /api/jobs`) lands — AC items around
"no duplicates" and "cursor state maintained" depend on it.

Fixes FE-695 (Model Library half).

## Screenshots (if applicable)

N/A — behavior change verified by unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12257-fix-model-library-auto-refresh-after-upload-plus-r-key-fallback-3606d73d3650811a8895ef6e3ef2b4b8)
by [Unito](https://www.unito.io)
2026-05-16 13:41:25 +00:00

294 lines
9.1 KiB
TypeScript

import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ModelFile } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
function _findInMetadata(
metadata: Record<string, string | null>,
...keys: string[]
): string | null {
for (const key of keys) {
if (key in metadata) {
const value = metadata[key]
return value || null
}
for (const k in metadata) {
if (k.endsWith(key)) {
const value = metadata[k]
return value || null
}
}
}
return null
}
/** Defines and holds metadata for a model */
export class ComfyModelDef {
/** Path to the model */
readonly path_index: number
/** Proper filename of the model */
readonly file_name: string
/** Normalized filename of the model, with all backslashes replaced with forward slashes */
readonly normalized_file_name: string
/** Directory containing the model, eg 'checkpoints' */
readonly directory: string
/** Simplified copy of name, used as a default title. Excludes the directory and the '.safetensors' file extension */
readonly simplified_file_name: string
/** Key for the model, used to uniquely identify the model. */
readonly key: string
/** Title / display name of the model, sometimes same as the name but not always */
title: string
/** Metadata: architecture ID for the model, such as 'stable-diffusion-xl-v1-base' */
architecture_id: string = ''
/** Metadata: author of the model */
author: string = ''
/** Metadata: resolution of the model, eg '1024x1024' */
resolution: string = ''
/** Metadata: description of the model */
description: string = ''
/** Metadata: usage hint for the model */
usage_hint: string = ''
/** Metadata: trigger phrase for the model */
trigger_phrase: string = ''
/** Metadata: tags list for the model */
tags: string[] = []
/** Metadata: image for the model */
image: string = ''
/** Whether the model metadata has been loaded from the server, used for `load()` */
has_loaded_metadata: boolean = false
/** If true, a metadata load request has been triggered, but may or may not yet have finished loading */
is_load_requested: boolean = false
/** A string full of auto-computed lowercase-only searchable text for this model */
searchable: string = ''
constructor(name: string, directory: string, pathIndex: number) {
this.path_index = pathIndex
this.file_name = name
this.normalized_file_name = name.replaceAll('\\', '/')
this.simplified_file_name = this.normalized_file_name.split('/').pop() ?? ''
if (this.simplified_file_name.endsWith('.safetensors')) {
this.simplified_file_name = this.simplified_file_name.slice(
0,
-'.safetensors'.length
)
}
this.title = this.simplified_file_name
this.directory = directory
this.key = `${directory}/${this.normalized_file_name}`
this.updateSearchable()
}
updateSearchable() {
this.searchable = [
this.file_name,
this.title,
this.author,
this.description,
this.trigger_phrase,
this.tags.join(', ')
]
.join('\n')
.toLowerCase()
}
/** Loads the model metadata from the server, filling in this object if data is available */
async load(): Promise<void> {
if (this.has_loaded_metadata || this.is_load_requested) {
return
}
this.is_load_requested = true
try {
const metadata = await api.viewMetadata(this.directory, this.file_name)
if (!metadata) {
return
}
this.title =
_findInMetadata(
metadata,
'modelspec.title',
'title',
'display_name',
'name'
) || this.title
this.architecture_id =
_findInMetadata(metadata, 'modelspec.architecture', 'architecture') ||
''
this.author =
_findInMetadata(metadata, 'modelspec.author', 'author') || ''
this.description =
_findInMetadata(metadata, 'modelspec.description', 'description') || ''
this.resolution =
_findInMetadata(metadata, 'modelspec.resolution', 'resolution') || ''
this.usage_hint =
_findInMetadata(metadata, 'modelspec.usage_hint', 'usage_hint') || ''
this.trigger_phrase =
_findInMetadata(
metadata,
'modelspec.trigger_phrase',
'trigger_phrase'
) || ''
this.image =
_findInMetadata(
metadata,
'modelspec.thumbnail',
'thumbnail',
'image',
'icon'
) || ''
const tagsCommaSeparated =
_findInMetadata(metadata, 'modelspec.tags', 'tags') || ''
this.tags = tagsCommaSeparated.split(',').map((tag) => tag.trim())
this.has_loaded_metadata = true
this.updateSearchable()
} catch (error) {
console.error('Error loading model metadata', this.file_name, this, error)
}
}
}
export enum ResourceState {
Uninitialized,
Loading,
Loaded
}
export class ModelFolder {
/** Models in this folder */
models: Record<string, ComfyModelDef> = {}
state: ResourceState = ResourceState.Uninitialized
constructor(
public directory: string,
private getModelsFunc: (folder: string) => Promise<ModelFile[]>
) {}
get key(): string {
return this.directory + '/'
}
/**
* Loads the models in this folder from the server
*/
async load() {
if (this.state !== ResourceState.Uninitialized) {
return this
}
this.state = ResourceState.Loading
const models = await this.getModelsFunc(this.directory)
for (const model of models) {
this.models[`${model.pathIndex}/${model.name}`] = new ComfyModelDef(
model.name,
this.directory,
model.pathIndex
)
}
this.state = ResourceState.Loaded
return this
}
}
/** Model store handler, wraps individual per-folder model stores */
export const useModelStore = defineStore('models', () => {
const settingStore = useSettingStore()
const modelFolderNames = ref<string[]>([])
const modelFolderByName = ref<Record<string, ModelFolder>>({})
const modelFolders = computed<ModelFolder[]>(() =>
modelFolderNames.value.map(
(folderName) => modelFolderByName.value[folderName]
)
)
const models = computed<ComfyModelDef[]>(() =>
modelFolders.value.flatMap((folder) => Object.values(folder.models))
)
function createGetModelsFunc(): (folder: string) => Promise<ModelFile[]> {
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
return useAssetAPI
? (folder) => assetService.getAssetModels(folder)
: (folder) => api.getModels(folder)
}
/**
* Loads the model folders from the server
*/
async function loadModelFolders() {
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
const resData = useAssetAPI
? await assetService.getAssetModelFolders()
: await api.getModelFolders()
modelFolderNames.value = resData.map((folder) => folder.name)
modelFolderByName.value = {}
const getModelsFunc = createGetModelsFunc()
for (const folderName of modelFolderNames.value) {
modelFolderByName.value[folderName] = new ModelFolder(
folderName,
getModelsFunc
)
}
}
async function getLoadedModelFolder(
folderName: string
): Promise<ModelFolder | null> {
const folder = modelFolderByName.value[folderName]
return folder ? await folder.load() : null
}
/**
* Loads all model folders' contents from the server
*/
async function loadModels() {
return Promise.all(modelFolders.value.map((folder) => folder.load()))
}
/**
* Discards the cache for a single folder and re-loads its contents.
* Use when on-disk contents of that folder have changed (e.g. after upload).
* Falls back to refreshing the whole library when the folder is unknown so
* a newly-introduced folder type is picked up without dropping other
* folders' loaded contents.
*/
async function refreshModelFolder(folderName: string) {
if (!(folderName in modelFolderByName.value)) {
await refresh()
return
}
const folder = new ModelFolder(folderName, createGetModelsFunc())
await folder.load()
modelFolderByName.value[folderName] = folder
}
/**
* Refreshes the folder structure and re-loads any folder whose contents
* had previously been loaded. Used by manual refresh actions ("r" key,
* sidebar refresh button) to pick up on-disk changes without losing the
* currently-visible contents.
*/
async function refresh() {
const previouslyLoaded = modelFolders.value
.filter((folder) => folder.state === ResourceState.Loaded)
.map((folder) => folder.directory)
await loadModelFolders()
await Promise.all(
previouslyLoaded
.filter((name) => name in modelFolderByName.value)
.map((name) => modelFolderByName.value[name].load())
)
}
return {
models,
modelFolders,
loadModelFolders,
loadModels,
getLoadedModelFolder,
refreshModelFolder,
refresh
}
})