mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
## 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)
294 lines
9.1 KiB
TypeScript
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
|
|
}
|
|
})
|