diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..2f6e26fdd --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,25 @@ +# Migration guide + +## ApplicationCatalog + +Before **Podman AI Lab** `v1.2.0` the [user-catalog](./PACKAGING-GUIDE.md#applicationcatalog) was not versioned. +Starting from `v1.2.0` the user-catalog require to have a `version` property. + +The list of catalog versions can be found in [packages/backend/src/utils/catalogUtils.ts](https://github.com/containers/podman-desktop-extension-ai-lab/blob/main/packages/backend/src/utils/catalogUtils.ts) + +The catalog has its own version number, as we may not require to update it with every update. It will follow semantic versioning convention. + +## `None` to Catalog `1.0` + +`None` represents any catalog version prior to the first versioning. + +Version `1.0` of the catalog adds an important property to models `backend`, defining the type of framework required by the model to run (E.g. LLamaCPP, WhisperCPP). + +### Changelog + +- property `backend` on models and recipes. Ref https://github.com/containers/podman-desktop-extension-ai-lab/pull/1186 +- property `models` is now **deprecated** and useless. Ref https://github.com/containers/podman-desktop-extension-ai-lab/pull/1210 +- property `recommended` has been added. Ref https://github.com/containers/podman-desktop-extension-ai-lab/pull/1210 +- optional `chatFormat` added. Ref: https://github.com/containers/podman-desktop-extension-ai-lab/pull/868 +- optional `sha256` property on models. Ref https://github.com/containers/podman-desktop-extension-ai-lab/pull/1078 + diff --git a/packages/backend/src/managers/catalogManager.spec.ts b/packages/backend/src/managers/catalogManager.spec.ts index 122e31b6d..abbfbec64 100644 --- a/packages/backend/src/managers/catalogManager.spec.ts +++ b/packages/backend/src/managers/catalogManager.spec.ts @@ -21,7 +21,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import content from '../tests/ai-test.json'; import userContent from '../tests/ai-user-test.json'; -import { type Webview, EventEmitter } from '@podman-desktop/api'; +import { type Webview, EventEmitter, window } from '@podman-desktop/api'; import { CatalogManager } from './catalogManager'; import type { Stats } from 'node:fs'; @@ -62,6 +62,7 @@ vi.mock('@podman-desktop/api', async () => { EventEmitter: vi.fn(), window: { withProgress: mocks.withProgressMock, + showNotification: vi.fn(), }, ProgressLocation: { TASK_WIDGET: 'TASK_WIDGET', @@ -82,6 +83,22 @@ beforeEach(async () => { vi.resetAllMocks(); const appUserDirectory = '.'; + + vi.mock('node:fs'); + + // mock EventEmitter logic + vi.mocked(EventEmitter).mockImplementation(() => { + const listeners: ((value: unknown) => void)[] = []; + return { + event: vi.fn().mockImplementation(callback => { + listeners.push(callback); + }), + fire: vi.fn().mockImplementation((content: unknown) => { + listeners.forEach(listener => listener(content)); + }), + } as unknown as EventEmitter; + }); + // Creating CatalogManager catalogManager = new CatalogManager( { @@ -89,19 +106,6 @@ beforeEach(async () => { } as unknown as Webview, appUserDirectory, ); - - vi.mock('node:fs'); - - const listeners: ((value: unknown) => void)[] = []; - - vi.mocked(EventEmitter).mockReturnValue({ - event: vi.fn().mockImplementation(callback => { - listeners.push(callback); - }), - fire: vi.fn().mockImplementation((content: unknown) => { - listeners.forEach(listener => listener(content)); - }), - } as unknown as EventEmitter); }); describe('invalid user catalog', () => { @@ -263,3 +267,36 @@ test('catalog should use user items in favour of default', async () => { test('default catalog should have latest version', () => { expect(version).toBe(CatalogFormat.CURRENT); }); + +test('wrong catalog version should create a notification', () => { + catalogManager['onUserCatalogUpdate']({ version: CatalogFormat.UNKNOWN }); + + expect(window.showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Incompatible user-catalog', + }), + ); +}); + +test('malformed catalog should create a notification', async () => { + vi.mocked(existsSync).mockReturnValue(false); + vi.spyOn(path, 'resolve').mockReturnValue('path'); + + catalogManager['onUserCatalogUpdate']({ + version: CatalogFormat.CURRENT, + models: [ + { + fakeProperty: 'hello', + }, + ], + recipes: [], + categories: [], + }); + + expect(window.showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error loading the user catalog', + body: 'Something went wrong while trying to load the user catalog: Error: invalid model format', + }), + ); +}); diff --git a/packages/backend/src/managers/catalogManager.ts b/packages/backend/src/managers/catalogManager.ts index fa48ae9b7..d3aa9f97a 100644 --- a/packages/backend/src/managers/catalogManager.ts +++ b/packages/backend/src/managers/catalogManager.ts @@ -23,7 +23,7 @@ import defaultCatalog from '../assets/ai.json'; import type { Recipe } from '@shared/src/models/IRecipe'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; import { Messages } from '@shared/Messages'; -import { type Disposable, type Event, EventEmitter, type Webview } from '@podman-desktop/api'; +import { type Disposable, type Event, EventEmitter, type Webview, window } from '@podman-desktop/api'; import { JsonWatcher } from '../utils/JsonWatcher'; import { Publisher } from '../utils/Publisher'; import type { LocalModelImportInfo } from '@shared/src/models/ILocalModelInfo'; @@ -37,7 +37,8 @@ export class CatalogManager extends Publisher implements Dis readonly onUpdate: Event = this._onUpdate.event; private catalog: ApplicationCatalog; - #disposables: Disposable[]; + #jsonWatcher: JsonWatcher | undefined; + #notification: Disposable | undefined; constructor( webview: Webview, @@ -51,8 +52,6 @@ export class CatalogManager extends Publisher implements Dis models: [], recipes: [], }; - - this.#disposables = []; } /** @@ -60,16 +59,14 @@ export class CatalogManager extends Publisher implements Dis */ init(): void { // Creating a json watcher - const jsonWatcher: JsonWatcher = new JsonWatcher(this.getUserCatalogPath(), { + this.#jsonWatcher = new JsonWatcher(this.getUserCatalogPath(), { version: CatalogFormat.CURRENT, recipes: [], models: [], categories: [], }); - jsonWatcher.onContentUpdated(content => this.onUserCatalogUpdate(content)); - jsonWatcher.init(); - - this.#disposables.push(jsonWatcher); + this.#jsonWatcher.onContentUpdated(content => this.onUserCatalogUpdate(content)); + this.#jsonWatcher.init(); } private loadDefaultCatalog(): void { @@ -91,6 +88,15 @@ export class CatalogManager extends Publisher implements Dis if (userCatalogFormat !== CatalogFormat.CURRENT) { this.loadDefaultCatalog(); + if (!this.#notification) { + this.#notification = window.showNotification({ + type: 'error', + title: 'Incompatible user-catalog', + body: `The catalog is using an older version of the catalog incompatible with current version ${CatalogFormat.CURRENT}.`, + markdownActions: + ':button[See migration guide]{href=https://github.com/containers/podman-desktop-extension-ai-lab/blob/main/MIGRATION.md title="Migration guide"}', + }); + } console.error( `the user-catalog provided is using version ${userCatalogFormat} expected ${CatalogFormat.CURRENT}. You can follow the migration guide.`, ); @@ -100,8 +106,19 @@ export class CatalogManager extends Publisher implements Dis // merging default catalog with user catalog try { this.catalog = merge(sanitize(defaultCatalog), sanitize({ ...content, version: userCatalogFormat })); + + // reset notification if everything went smoothly + this.#notification?.dispose(); + this.#notification = undefined; } catch (err: unknown) { - console.warn(err); + if (!this.#notification) { + this.#notification = window.showNotification({ + type: 'error', + title: 'Error loading the user catalog', + body: `Something went wrong while trying to load the user catalog: ${String(err)}`, + }); + } + console.error(err); this.loadDefaultCatalog(); } @@ -114,7 +131,8 @@ export class CatalogManager extends Publisher implements Dis } dispose(): void { - this.#disposables.forEach(watcher => watcher.dispose()); + this.#jsonWatcher?.dispose(); + this.#notification?.dispose(); } public getCatalog(): ApplicationCatalog {