-
Notifications
You must be signed in to change notification settings - Fork 151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
initial model store #674
initial model store #674
Conversation
|
||
/** Defines and holds metadata for a model */ | ||
export class ComfyModelDef { | ||
/** Proper filename of the model */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should define the schema in zod for easier parsing and validation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this isn't a schema or api type, it's just temporary local data storage. model metadata doesn't come in a reliable format, at least not currently.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unless you think we should build a whole "proper metadata" api on the ComfyUI serverside and then just read it for frontend? would make sense to do too tbh
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Transformed zod code:
import { z } from 'zod'
const zModelMetadata = z.object({
'modelspec.title': z.string().optional(),
title: z.string().optional(),
display_name: z.string().optional(),
name: z.string().optional(),
'modelspec.architecture': z.string().optional(),
architecture: z.string().optional(),
'modelspec.author': z.string().optional(),
author: z.string().optional(),
'modelspec.description': z.string().optional(),
description: z.string().optional(),
'modelspec.resolution': z.string().optional(),
resolution: z.string().optional(),
'modelspec.usage_hint': z.string().optional(),
usage_hint: z.string().optional(),
'modelspec.trigger_phrase': z.string().optional(),
trigger_phrase: z.string().optional(),
'modelspec.tags': z.string().optional(),
tags: z.string().optional(),
'modelspec.thumbnail': z.string().optional(),
thumbnail: z.string().optional(),
image: z.string().optional(),
icon: z.string().optional()
}).passthrough()
export const zComfyModelDef = z.object({
name: z.string(),
directory: z.string(),
title: z.string(),
architecture_id: z.string(),
author: z.string(),
resolution: z.string(),
description: z.string(),
usage_hint: z.string(),
trigger_phrase: z.string(),
tags: z.array(z.string()),
image: z.string(),
has_loaded_metadata: z.boolean()
})
export type ComfyModelDef = z.infer<typeof zComfyModelDef>
export function parseComfyModelDef(name: string, directory: string, metadata: z.infer<typeof zModelMetadata>): ComfyModelDef {
const parsedMetadata = zModelMetadata.parse(metadata)
const title = parsedMetadata['modelspec.title'] ||
parsedMetadata.title ||
parsedMetadata.display_name ||
parsedMetadata.name ||
name
const tagsString = parsedMetadata['modelspec.tags'] || parsedMetadata.tags || ''
const tags = tagsString.split(',').map(tag => tag.trim())
const image = parsedMetadata['modelspec.thumbnail'] ||
parsedMetadata.thumbnail ||
parsedMetadata.image ||
parsedMetadata.icon ||
''
return zComfyModelDef.parse({
name,
directory,
title,
architecture_id: parsedMetadata['modelspec.architecture'] || parsedMetadata.architecture || '',
author: parsedMetadata['modelspec.author'] || parsedMetadata.author || '',
resolution: parsedMetadata['modelspec.resolution'] || parsedMetadata.resolution || '',
description: parsedMetadata['modelspec.description'] || parsedMetadata.description || '',
usage_hint: parsedMetadata['modelspec.usage_hint'] || parsedMetadata.usage_hint || '',
trigger_phrase: parsedMetadata['modelspec.trigger_phrase'] || parsedMetadata.trigger_phrase || '',
tags,
image,
has_loaded_metadata: true
})
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But why tho
this isn't a schema or api type, it's just temporary local data storage. model metadata doesn't come in a reliable format, at least not currently.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
splitting object type would introduce significant complexity to practical usage, and not make it any easier to tell. It's not defined by "intuition" it's easily checkable with the boolean. More practically in most cases the difference does entirely matter. The times it matters are cases where you actively want the metadata so you'll check the bool and/or await o.load()
to ensure that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Separate interface is to avoid this boolean flag check everywhere when you need to use the value. Imagine every time you access any field in the structure you have to check a boolean flag and optionally call the load function. The ensure o.load() will also make tracking when/where the data is fetched very hard as there will be a ton of callsites to load.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW, I'm also fine if you make this explicitly lazy load, i.e. fetch data on first attempt to access fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is an explicit lazy load design, yes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, we can continue with this, and refactor later if necessary. Can you add some tests as requested by the previous comment?
the inner ModelStore (per-folder) can't be pinia because its made of temporary instances, but it can be reactive
the main store is now Pinia, the other values are all now reactive (as far as I can tell, pinia is limited to global scoped stores, so the sub-stores can't be pinia directly, but they can easily be made reactive) |
For sub store that has smaller scope (A subtree of the component tree), I would suggest using Vue's provide/inject mechanism for dependency injection. https://vuejs.org/guide/components/provide-inject |
This... leaves me very confused about how that would even work, and also looks like significant added complexity. Note there's not vue components here, this is just simple data. |
I thought you are talking about context that are specific to a subtree, which are the opposite to global store. An example of subtree context is if we have multiple workflows hosted in multiple containers, each component used in the container should be able to know which workflow the container is holding, but this local store is not very necessary if the nested level if not very deep. A global store with all the workflows will also work in this example. |
Workflows? huh? This is root -> model folders -> models in the folder it has to be in this structure due to the way upstream comfy handles models |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should also have some tests on the schame parsing and pinia store.
You can reference existing tests
- https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/tests-ui/tests/store/userFileStore.test.ts
- https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/tests-ui/tests/apiTypes.test.ts
Note
Code in review comments are generated by claude 3.5
|
||
/** Defines and holds metadata for a model */ | ||
export class ComfyModelDef { | ||
/** Proper filename of the model */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Transformed zod code:
import { z } from 'zod'
const zModelMetadata = z.object({
'modelspec.title': z.string().optional(),
title: z.string().optional(),
display_name: z.string().optional(),
name: z.string().optional(),
'modelspec.architecture': z.string().optional(),
architecture: z.string().optional(),
'modelspec.author': z.string().optional(),
author: z.string().optional(),
'modelspec.description': z.string().optional(),
description: z.string().optional(),
'modelspec.resolution': z.string().optional(),
resolution: z.string().optional(),
'modelspec.usage_hint': z.string().optional(),
usage_hint: z.string().optional(),
'modelspec.trigger_phrase': z.string().optional(),
trigger_phrase: z.string().optional(),
'modelspec.tags': z.string().optional(),
tags: z.string().optional(),
'modelspec.thumbnail': z.string().optional(),
thumbnail: z.string().optional(),
image: z.string().optional(),
icon: z.string().optional()
}).passthrough()
export const zComfyModelDef = z.object({
name: z.string(),
directory: z.string(),
title: z.string(),
architecture_id: z.string(),
author: z.string(),
resolution: z.string(),
description: z.string(),
usage_hint: z.string(),
trigger_phrase: z.string(),
tags: z.array(z.string()),
image: z.string(),
has_loaded_metadata: z.boolean()
})
export type ComfyModelDef = z.infer<typeof zComfyModelDef>
export function parseComfyModelDef(name: string, directory: string, metadata: z.infer<typeof zModelMetadata>): ComfyModelDef {
const parsedMetadata = zModelMetadata.parse(metadata)
const title = parsedMetadata['modelspec.title'] ||
parsedMetadata.title ||
parsedMetadata.display_name ||
parsedMetadata.name ||
name
const tagsString = parsedMetadata['modelspec.tags'] || parsedMetadata.tags || ''
const tags = tagsString.split(',').map(tag => tag.trim())
const image = parsedMetadata['modelspec.thumbnail'] ||
parsedMetadata.thumbnail ||
parsedMetadata.image ||
parsedMetadata.icon ||
''
return zComfyModelDef.parse({
name,
directory,
title,
architecture_id: parsedMetadata['modelspec.architecture'] || parsedMetadata.architecture || '',
author: parsedMetadata['modelspec.author'] || parsedMetadata.author || '',
resolution: parsedMetadata['modelspec.resolution'] || parsedMetadata.resolution || '',
description: parsedMetadata['modelspec.description'] || parsedMetadata.description || '',
usage_hint: parsedMetadata['modelspec.usage_hint'] || parsedMetadata.usage_hint || '',
trigger_phrase: parsedMetadata['modelspec.trigger_phrase'] || parsedMetadata.trigger_phrase || '',
tags,
image,
has_loaded_metadata: true
})
}
export class ModelStore { | ||
models: Record<string, ComfyModelDef> = reactive({}) | ||
|
||
constructor(directory: string, models: string[]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Instead of using a class for ModelStore, it would be better to incorporate this logic directly into the Pinia store. This would make the store more self-contained and easier to manage.
- The reactive wrapper is not necessary when defining state in a Pinia store, as Pinia automatically makes the state reactive.
Here's how you could refactor this to better align with Pinia best practices:
import { defineStore } from 'pinia'
import { ComfyModelDef } from './ComfyModelDef' // Assume this is moved to a separate file
export const useModelStore = defineStore('modelStore', {
state: () => ({
modelStoreMap: {} as Record<string, Record<string, ComfyModelDef>>
}),
actions: {
async getModelsInFolderCached(folder: string) {
if (folder in this.modelStoreMap) {
return this.modelStoreMap[folder]
}
// TODO: needs a lock to avoid overlapping calls
const models = await api.getModels(folder)
if (!models) {
return null
}
this.modelStoreMap[folder] = {}
for (const model of models) {
this.modelStoreMap[folder][model] = new ComfyModelDef(model, folder)
}
return this.modelStoreMap[folder]
},
async loadModelMetadata(folder: string, modelName: string) {
if (this.modelStoreMap[folder]?.[modelName]) {
await this.modelStoreMap[folder][modelName].load()
}
},
clearCache() {
this.modelStoreMap = {}
}
}
})
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pinia best practices increasingly seem to be directly contradictory to general programming best practices. "Don't use a subclass because pinia can't do that, just shove it all into one" seems more like an argument against pinia than anything else
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would still recommend you follow the direct storage type Record<string, Record<string, ComfyModelDef>>
. The store is essentially storing a collection of the ModelDef, and the aux ModelStore
is just provide a way to access ModelDef by name. You can define getter (computed state) later to add other ways to access data Record<modelName, Record<modelDir, ComfyModelDef>>
. Following your approach, we would need a ModelStoreByDir to manage that ownership.
The first principle is that Pinia store should own all objects it manages and the method to access them.
|
||
/** Defines and holds metadata for a model */ | ||
export class ComfyModelDef { | ||
/** Proper filename of the model */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you don't want to define the interface with zod, you can define it in plain TS interface. The current way to initialize the object is not very flexible. Let's say if later we accept the parsed data from backend directly, you would need to adjust the constructor signature.
So my suggestion here is an interface definition ComfyModel, and a static factory function to create ComfyModel from the data source. This also elimiates the middle state where the class is constructed but before the load function is called. (Is there any reason for the middle state to exist?)
src/stores/modelStore.ts
Outdated
/** Metadata: resolution of the model, eg '1024x1024' */ | ||
resolution = ref('') | ||
/** Metadata: description of the model */ | ||
description = ref('') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Drop all these ref
, as Vue's ref is deep, i.e. as long as we have Pinia manage a collection of ComfyModel, the nested object trees all become reactive.
|
||
/** Defines and holds metadata for a model */ | ||
export class ComfyModelDef { | ||
/** Proper filename of the model */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, we can continue with this, and refactor later if necessary. Can you add some tests as requested by the previous comment?
src/stores/modelStore.ts
Outdated
|
||
/** Model store for a folder */ | ||
export class ModelStore { | ||
models: Record<string, ComfyModelDef> = reactive({}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reactive
can be dropped here, as anything stored in Pinia is marked as reactive deeply (The whole recursive object tree are marked as reactive automatically)
src/stores/modelStore.ts
Outdated
if (!models) { | ||
return null | ||
} | ||
const store = reactive(new ModelStore(folder, models)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here. reactive
can be dropped, as anything stored in Pinia is marked as reactive deeply (The whole recursive object tree are marked as reactive automatically)
reactive/ref removed (not confident that's actually correct to do tbh but if it's an issue can readd later). Added simple unit tests for the core expectations of how it should work, included some potential ways it might go wrong to validate against |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM with minor nits.
;(api.viewMetadata as jest.Mock).mockImplementation((_, model) => { | ||
if (model === 'noinfo.safetensors') { | ||
return Promise.resolve({}) | ||
} | ||
return Promise.resolve({ | ||
'modelspec.title': `Title of ${model}`, | ||
display_name: 'Should not show', | ||
'modelspec.architecture': 'stable-diffusion-xl-base-v1', | ||
'modelspec.author': `Author of ${model}`, | ||
'modelspec.description': `Description of ${model}`, | ||
'modelspec.resolution': '1024x1024', | ||
trigger_phrase: `Trigger phrase of ${model}`, | ||
usage_hint: `Usage hint of ${model}`, | ||
tags: `tags,for,${model}` | ||
}) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This mock should live outside test body as other tests also depend on this mock.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried putting it in the initial jest.mock
call at the top, it was ignored. I tried putting it in the describe block outside the it
block, it was ignored. I tried putting it in the beforeEach, it was ignored. I don't know how to make it apply from outside of an it
block. They do however run linearly so being in the first block applies it to all of them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you can wrap it as a function and call it in test case that depends on it. Doing it in a test case and let other subsequent test case depends on it should be avoided as this makes us unable to disable individual test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fair enough, done
Initial model list + metadata store handler, moving the model list out of
app.ts
into it's own handler. This is a prerequisite to full model management interface. Also addsview_metadata
API route + some handling attached to it in modelStore.PR'd this before anything else as @huchenlei I imagine you'll have some comment on the structure here? So better to get that reviewed before I go too deep into using it.
The other
stores/
files all usedefineStore
which to my eye is some indecipherable Enterprise™ overcomplication of defining a class and a singleton? I don't know what the heck is going on there. For modelStore I gave it a direct impl instead of trying to match that pattern.(Technically more like a service pattern but it's a datastore.)
ps, the metadata code looked so nice before
npm run format
ruined it :(