Skip to content

Commit cf01470

Browse files
axel7083lstocchi
andauthored
fix: user catalog (#916)
* fix: catalog user Signed-off-by: axel7083 <[email protected]> * fix: models manager tests Signed-off-by: axel7083 <[email protected]> * fix: README Signed-off-by: axel7083 <[email protected]> * Update README.md Co-authored-by: Luca Stocchi <[email protected]> Signed-off-by: axel7083 <[email protected]> * fix: allow the user catalog to overwrite default items Signed-off-by: axel7083 <[email protected]> * fix: tests Signed-off-by: axel7083 <[email protected]> --------- Signed-off-by: axel7083 <[email protected]> Co-authored-by: Luca Stocchi <[email protected]>
1 parent c97f233 commit cf01470

File tree

7 files changed

+231
-63
lines changed

7 files changed

+231
-63
lines changed

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,17 @@ For the time being, please consider the following actions:
129129
4. Remove the containers related to AI
130130
5. Cleanup your local clone of the recipes: `$HOME/podman-desktop/ai-lab`
131131

132-
### Providing a custom catalog
132+
### 📖 Providing a custom catalog
133133

134-
The extension provides a default catalog, but you can build your own catalog by creating a file `$HOME/podman-desktop/ai-lab/catalog.json`.
134+
The extension provides by default a curated list of recipes, models and categories. However, this system is extensible and you can define your own.
135135

136-
The catalog provides lists of categories, recipes, and models.
136+
To enhance the existing catalog, you can create a file located in the extension storage folder `$HOME/.local/share/containers/podman-desktop/extensions-storage/redhat.ai-lab/user-catalog.json`.
137137

138-
Each recipe can belong to one or several categories. Each model can be used by one or several recipes.
138+
It must follow the same format as the default catalog [in the sources of the extension](https://github.com/containers/podman-desktop-extension-ai-lab/blob/main/packages/backend/src/assets/ai.json).
139139

140-
The format of the catalog is not stable nor versioned yet, you can see the current catalog's format [in the sources of the extension](https://github.com/containers/podman-desktop-extension-ai-lab/blob/main/packages/backend/src/assets/ai.json).
140+
> :information_source: The default behaviour is to append the items of the user's catalog to the default one.
141+
142+
> :warning: Each item (recipes, models or categories) has a unique id, when conflict between the default catalog and the user one are found, the user's items overwrite the defaults.
141143
142144
### Packaging sample applications
143145

packages/backend/src/managers/catalogManager.spec.ts

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { promises, existsSync } from 'node:fs';
2929
import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog';
3030
import path from 'node:path';
3131

32-
vi.mock('./ai.json', () => {
32+
vi.mock('../assets/ai.json', () => {
3333
return {
3434
default: content,
3535
};
@@ -104,12 +104,12 @@ describe('invalid user catalog', () => {
104104
});
105105

106106
test('expect correct model is returned with valid id', () => {
107-
const model = catalogManager.getModelById('hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M');
107+
const model = catalogManager.getModelById('llama-2-7b-chat.Q5_K_S');
108108
expect(model).toBeDefined();
109-
expect(model.name).toEqual('TheBloke/Mistral-7B-Instruct-v0.1-GGUF');
109+
expect(model.name).toEqual('Llama-2-7B-Chat-GGUF');
110110
expect(model.registry).toEqual('Hugging Face');
111111
expect(model.url).toEqual(
112-
'https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf',
112+
'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf',
113113
);
114114
});
115115

@@ -123,12 +123,12 @@ test('expect correct model is returned from default catalog with valid id when n
123123
catalogManager.init();
124124
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
125125

126-
const model = catalogManager.getModelById('hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M');
126+
const model = catalogManager.getModelById('llama-2-7b-chat.Q5_K_S');
127127
expect(model).toBeDefined();
128-
expect(model.name).toEqual('TheBloke/Mistral-7B-Instruct-v0.1-GGUF');
128+
expect(model.name).toEqual('Llama-2-7B-Chat-GGUF');
129129
expect(model.registry).toEqual('Hugging Face');
130130
expect(model.url).toEqual(
131-
'https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf',
131+
'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf',
132132
);
133133
});
134134

@@ -137,7 +137,7 @@ test('expect correct model is returned with valid id when the user catalog is va
137137
vi.spyOn(promises, 'readFile').mockResolvedValue(JSON.stringify(userContent));
138138

139139
catalogManager.init();
140-
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
140+
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
141141

142142
const model = catalogManager.getModelById('model1');
143143
expect(model).toBeDefined();
@@ -176,7 +176,7 @@ test('expect to call writeFile in addLocalModelsToCatalog with catalog updated',
176176
});
177177
const writeFileMock = vi.spyOn(promises, 'writeFile').mockResolvedValue();
178178

179-
await catalogManager.addLocalModelsToCatalog([
179+
await catalogManager.importUserModels([
180180
{
181181
name: 'custom-model',
182182
path: '/root/path/file.gguf',
@@ -199,7 +199,65 @@ test('expect to call writeFile in removeLocalModelFromCatalog with catalog updat
199199
const updatedCatalog: ApplicationCatalog = Object.assign({}, userContent);
200200
updatedCatalog.models = updatedCatalog.models.filter(m => m.id !== 'model1');
201201

202-
await catalogManager.removeLocalModelFromCatalog('model1');
202+
await catalogManager.removeUserModel('model1');
203203

204204
expect(writeFileMock).toBeCalledWith('path', JSON.stringify(updatedCatalog, undefined, 2), 'utf-8');
205205
});
206+
207+
test('catalog should be the combination of user catalog and default catalog', async () => {
208+
vi.mocked(existsSync).mockReturnValue(true);
209+
vi.spyOn(promises, 'readFile').mockResolvedValue(JSON.stringify(userContent));
210+
211+
catalogManager.init();
212+
await vi.waitUntil(() => catalogManager.getModels().length > userContent.models.length);
213+
214+
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
215+
vi.spyOn(promises, 'stat').mockResolvedValue({
216+
size: 1,
217+
mtime: mtimeDate,
218+
} as Stats);
219+
vi.spyOn(path, 'resolve').mockReturnValue('path');
220+
221+
const catalog = catalogManager.getCatalog();
222+
223+
expect(catalog).toEqual({
224+
recipes: [...content.recipes, ...userContent.recipes],
225+
models: [...content.models, ...userContent.models],
226+
categories: [...content.categories, ...userContent.categories],
227+
});
228+
});
229+
230+
test('catalog should use user items in favour of default', async () => {
231+
vi.mocked(existsSync).mockReturnValue(true);
232+
233+
const overwriteFullCatalog: ApplicationCatalog = {
234+
recipes: content.recipes.map(recipe => ({
235+
...recipe,
236+
name: 'user-recipe-overwrite',
237+
})),
238+
models: content.models.map(model => ({
239+
...model,
240+
name: 'user-model-overwrite',
241+
})),
242+
categories: content.categories.map(category => ({
243+
...category,
244+
name: 'user-model-overwrite',
245+
})),
246+
};
247+
248+
vi.spyOn(promises, 'readFile').mockResolvedValue(JSON.stringify(overwriteFullCatalog));
249+
250+
catalogManager.init();
251+
await vi.waitUntil(() => catalogManager.getModels().length > 0);
252+
253+
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
254+
vi.spyOn(promises, 'stat').mockResolvedValue({
255+
size: 1,
256+
mtime: mtimeDate,
257+
} as Stats);
258+
vi.spyOn(path, 'resolve').mockReturnValue('path');
259+
260+
const catalog = catalogManager.getCatalog();
261+
262+
expect(catalog).toEqual(overwriteFullCatalog);
263+
});

packages/backend/src/managers/catalogManager.ts

Lines changed: 150 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
***********************************************************************/
1818

1919
import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog';
20-
import { promises } from 'node:fs';
20+
import fs, { promises } from 'node:fs';
2121
import path from 'node:path';
2222
import defaultCatalog from '../assets/ai.json';
2323
import type { Recipe } from '@shared/src/models/IRecipe';
@@ -30,6 +30,8 @@ import type { LocalModelImportInfo } from '@shared/src/models/ILocalModelInfo';
3030

3131
export type catalogUpdateHandle = () => void;
3232

33+
export const USER_CATALOG = 'user-catalog.json';
34+
3335
export class CatalogManager extends Publisher<ApplicationCatalog> implements Disposable {
3436
private catalog: ApplicationCatalog;
3537
#catalogUpdateListeners: catalogUpdateHandle[];
@@ -51,31 +53,91 @@ export class CatalogManager extends Publisher<ApplicationCatalog> implements Dis
5153
this.#disposables = [];
5254
}
5355

56+
/**
57+
* The init method will start a watcher on the user catalog.json
58+
*/
5459
init(): void {
5560
// Creating a json watcher
56-
const jsonWatcher: JsonWatcher<ApplicationCatalog> = new JsonWatcher(
57-
path.resolve(this.appUserDirectory, 'catalog.json'),
58-
defaultCatalog,
59-
);
61+
const jsonWatcher: JsonWatcher<ApplicationCatalog> = new JsonWatcher(this.getUserCatalogPath(), {
62+
recipes: [],
63+
models: [],
64+
categories: [],
65+
});
6066
jsonWatcher.onContentUpdated(content => this.onCatalogUpdated(content));
6167
jsonWatcher.init();
6268

6369
this.#disposables.push(jsonWatcher);
6470
}
6571

72+
private loadDefaultCatalog(): void {
73+
this.catalog = defaultCatalog;
74+
this.notify();
75+
}
76+
6677
private onCatalogUpdated(content: ApplicationCatalog): void {
67-
// when reading the content on the catalog, the creation is just a string and we need to convert it back to a Date object
68-
content.models
69-
.filter(m => m.file?.creation)
70-
.forEach(m => {
71-
if (m.file?.creation) {
72-
m.file.creation = new Date(m.file.creation);
78+
if (typeof content !== 'object' || !('models' in content) || typeof content.models !== 'object') {
79+
this.loadDefaultCatalog();
80+
return;
81+
}
82+
83+
const sanitize = this.sanitize(content);
84+
this.catalog = {
85+
models: [...defaultCatalog.models.filter(a => !sanitize.models.some(b => a.id === b.id)), ...sanitize.models],
86+
recipes: [...defaultCatalog.recipes.filter(a => !sanitize.recipes.some(b => a.id === b.id)), ...sanitize.recipes],
87+
categories: [
88+
...defaultCatalog.categories.filter(a => !sanitize.categories.some(b => a.id === b.id)),
89+
...sanitize.categories,
90+
],
91+
};
92+
93+
this.notify();
94+
}
95+
96+
private sanitize(content: unknown): ApplicationCatalog {
97+
const output: ApplicationCatalog = {
98+
recipes: [],
99+
models: [],
100+
categories: [],
101+
};
102+
103+
if (!content || typeof content !== 'object') {
104+
console.warn('malformed application catalog content');
105+
return output;
106+
}
107+
108+
// ensure user's models are properly formatted
109+
if ('models' in content && typeof content.models === 'object' && Array.isArray(content.models)) {
110+
output.models = content.models.map(model => {
111+
// parse the creation date properly
112+
if (model.file?.creation) {
113+
return {
114+
...model,
115+
file: {
116+
...model.file,
117+
creation: new Date(model.file.creation),
118+
},
119+
};
73120
}
121+
return model;
74122
});
75-
this.catalog = content;
123+
}
76124

125+
// ensure user's recipes are properly formatted
126+
if ('recipes' in content && typeof content.recipes === 'object' && Array.isArray(content.recipes)) {
127+
output.recipes = content.recipes;
128+
}
129+
130+
// ensure user's categories are properly formatted
131+
if ('categories' in content && typeof content.categories === 'object' && Array.isArray(content.categories)) {
132+
output.categories = content.categories;
133+
}
134+
135+
return output;
136+
}
137+
138+
override notify() {
139+
super.notify();
77140
this.#catalogUpdateListeners.forEach(listener => listener());
78-
this.notify();
79141
}
80142

81143
onCatalogUpdate(listener: catalogUpdateHandle): Disposable {
@@ -117,39 +179,85 @@ export class CatalogManager extends Publisher<ApplicationCatalog> implements Dis
117179
return recipe;
118180
}
119181

120-
async addLocalModelsToCatalog(models: LocalModelImportInfo[]): Promise<void> {
121-
// we copy the current catalog in another object and update it with the model
122-
// then write it to the custom catalog path. If it exists it will be overwritten by default
123-
const tmpCatalog: ApplicationCatalog = Object.assign({}, this.catalog);
124-
125-
for (const model of models) {
126-
const statFile = await promises.stat(model.path);
127-
tmpCatalog.models.push({
128-
id: model.path,
129-
name: model.name,
130-
description: `Model imported from ${model.path}`,
131-
hw: 'CPU',
132-
file: {
133-
path: path.dirname(model.path),
134-
file: path.basename(model.path),
135-
size: statFile.size,
136-
creation: statFile.mtime,
137-
},
138-
memory: statFile.size,
139-
});
182+
/**
183+
* This method is used to imports user's local models.
184+
* @param localModels the models to imports
185+
*/
186+
async importUserModels(localModels: LocalModelImportInfo[]): Promise<void> {
187+
const userCatalogPath = this.getUserCatalogPath();
188+
let content: ApplicationCatalog;
189+
190+
// check if we already have an existing user's catalog
191+
if (fs.existsSync(userCatalogPath)) {
192+
const raw = await promises.readFile(userCatalogPath, 'utf-8');
193+
content = this.sanitize(JSON.parse(raw));
194+
} else {
195+
content = {
196+
recipes: [],
197+
models: [],
198+
categories: [],
199+
};
140200
}
141201

142-
const customCatalog = path.resolve(this.appUserDirectory, 'catalog.json');
143-
return promises.writeFile(customCatalog, JSON.stringify(tmpCatalog, undefined, 2), 'utf-8');
202+
// Transform local models into ModelInfo
203+
const models: ModelInfo[] = await Promise.all(
204+
localModels.map(async local => {
205+
const statFile = await promises.stat(local.path);
206+
return {
207+
id: local.path,
208+
name: local.name,
209+
description: `Model imported from ${local.path}`,
210+
hw: 'CPU',
211+
file: {
212+
path: path.dirname(local.path),
213+
file: path.basename(local.path),
214+
size: statFile.size,
215+
creation: statFile.mtime,
216+
},
217+
memory: statFile.size,
218+
};
219+
}),
220+
);
221+
222+
// Add all our models infos to the user's models catalog
223+
content.models.push(...models);
224+
225+
// overwrite the existing catalog
226+
return promises.writeFile(userCatalogPath, JSON.stringify(content, undefined, 2), 'utf-8');
144227
}
145228

146-
async removeLocalModelFromCatalog(modelId: string): Promise<void> {
147-
// we copy the current catalog in another object and remove from it the model with modelId
148-
// then write it to the custom catalog path.
149-
const tmpCatalog: ApplicationCatalog = Object.assign({}, this.catalog);
150-
tmpCatalog.models = tmpCatalog.models.filter(m => m.url !== '' && m.id !== modelId);
229+
/**
230+
* Remove a model from the user's catalog.
231+
* @param modelId
232+
*/
233+
async removeUserModel(modelId: string): Promise<void> {
234+
const userCatalogPath = this.getUserCatalogPath();
235+
if (!fs.existsSync(userCatalogPath)) {
236+
throw new Error('User catalog does not exist.');
237+
}
238+
239+
const raw = await promises.readFile(userCatalogPath, 'utf-8');
240+
const content = this.sanitize(JSON.parse(raw));
241+
242+
return promises.writeFile(
243+
userCatalogPath,
244+
JSON.stringify(
245+
{
246+
recipes: content.recipes,
247+
models: content.models.filter(model => model.id !== modelId),
248+
categories: content.categories,
249+
},
250+
undefined,
251+
2,
252+
),
253+
'utf-8',
254+
);
255+
}
151256

152-
const customCatalog = path.resolve(this.appUserDirectory, 'catalog.json');
153-
return promises.writeFile(customCatalog, JSON.stringify(tmpCatalog, undefined, 2), 'utf-8');
257+
/**
258+
* Return the path to the user catalog
259+
*/
260+
private getUserCatalogPath(): string {
261+
return path.resolve(this.appUserDirectory, USER_CATALOG);
154262
}
155263
}

0 commit comments

Comments
 (0)