diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index ab3423765..408ad551b 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -26,6 +26,7 @@ import type { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; import { StudioApiImpl } from './studio-api-impl'; import type { PlayGroundManager } from './playground'; import type { TaskRegistry } from './registries/TaskRegistry'; +import type { Webview } from '@podman-desktop/api'; import * as fs from 'node:fs'; @@ -45,6 +46,9 @@ beforeEach(async () => { {} as unknown as RecipeStatusRegistry, {} as unknown as TaskRegistry, {} as unknown as PlayGroundManager, + { + postMessage: vi.fn(), + } as unknown as Webview, ); vi.resetAllMocks(); vi.mock('node:fs'); @@ -56,8 +60,8 @@ describe('invalid user catalog', () => { await studioApiImpl.loadCatalog(); }); - test('expect correct model is returned with valid id', async () => { - const model = await studioApiImpl.getModelById('llama-2-7b-chat.Q5_K_S'); + test('expect correct model is returned with valid id', () => { + const model = studioApiImpl.getModelById('llama-2-7b-chat.Q5_K_S'); expect(model).toBeDefined(); expect(model.name).toEqual('Llama-2-7B-Chat-GGUF'); expect(model.registry).toEqual('Hugging Face'); @@ -66,41 +70,15 @@ describe('invalid user catalog', () => { ); }); - test('expect error if id does not correspond to any model', async () => { - await expect(() => studioApiImpl.getModelById('unknown')).rejects.toThrowError('No model found having id unknown'); - }); - - test('expect array of models based on list of ids', async () => { - const models = await studioApiImpl.getModelsByIds(['llama-2-7b-chat.Q5_K_S', 'albedobase-xl-1.3']); - expect(models).toBeDefined(); - expect(models.length).toBe(2); - expect(models[0].name).toEqual('Llama-2-7B-Chat-GGUF'); - expect(models[0].registry).toEqual('Hugging Face'); - expect(models[0].url).toEqual( - 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf', - ); - expect(models[1].name).toEqual('AlbedoBase XL 1.3'); - expect(models[1].registry).toEqual('Civital'); - expect(models[1].url).toEqual(''); - }); - - test('expect empty array if input list is empty', async () => { - const models = await studioApiImpl.getModelsByIds([]); - expect(models).toBeDefined(); - expect(models.length).toBe(0); - }); - - test('expect empty array if input list has ids that are not in the catalog', async () => { - const models = await studioApiImpl.getModelsByIds(['1', '2']); - expect(models).toBeDefined(); - expect(models.length).toBe(0); + test('expect error if id does not correspond to any model', () => { + expect(() => studioApiImpl.getModelById('unknown')).toThrowError('No model found having id unknown'); }); }); test('expect correct model is returned from default catalog with valid id when no user catalog exists', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); await studioApiImpl.loadCatalog(); - const model = await studioApiImpl.getModelById('llama-2-7b-chat.Q5_K_S'); + const model = studioApiImpl.getModelById('llama-2-7b-chat.Q5_K_S'); expect(model).toBeDefined(); expect(model.name).toEqual('Llama-2-7B-Chat-GGUF'); expect(model.registry).toEqual('Hugging Face'); @@ -113,7 +91,7 @@ test('expect correct model is returned with valid id when the user catalog is va vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs.promises, 'readFile').mockResolvedValue(JSON.stringify(userContent)); await studioApiImpl.loadCatalog(); - const model = await studioApiImpl.getModelById('model1'); + const model = studioApiImpl.getModelById('model1'); expect(model).toBeDefined(); expect(model.name).toEqual('Model 1'); expect(model.registry).toEqual('Hugging Face'); diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index 30f0c5ab6..58e29f35e 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -1,5 +1,4 @@ import type { StudioAPI } from '@shared/src/StudioAPI'; -import type { Category } from '@shared/src/models/ICategory'; import type { Recipe } from '@shared/src/models/IRecipe'; import defaultCatalog from './ai.json'; import type { ApplicationManager } from './managers/applicationManager'; @@ -12,6 +11,7 @@ import type { PlayGroundManager } from './playground'; import * as podmanDesktopApi from '@podman-desktop/api'; import type { QueryState } from '@shared/src/models/IPlaygroundQueryState'; import type { Catalog } from '@shared/src/models/ICatalog'; +import { MSG_NEW_CATALOG_STATE } from '@shared/Messages'; import * as path from 'node:path'; import * as fs from 'node:fs'; @@ -26,6 +26,7 @@ export class StudioApiImpl implements StudioAPI { private recipeStatusRegistry: RecipeStatusRegistry, private taskRegistry: TaskRegistry, private playgroundManager: PlayGroundManager, + private webview: podmanDesktopApi.Webview, ) { // We start with an empty catalog, for the methods to work before the catalog is loaded this.catalog = { @@ -37,24 +38,62 @@ export class StudioApiImpl implements StudioAPI { async loadCatalog() { const catalogPath = path.resolve(this.applicationManager.appUserDirectory, 'catalog.json'); + + try { + this.watchCatalogFile(catalogPath); // do not await, we want to do this async + } catch (err: unknown) { + console.error("unable to watch catalog file, changes to the catalog file won't be reflected to the UI", err); + } + try { if (!fs.existsSync(catalogPath)) { - this.setCatalog(defaultCatalog); + await this.setCatalog(defaultCatalog); return; } - // TODO(feloy): watch catalog file and update catalog with new content - const data = await fs.promises.readFile(catalogPath, 'utf-8'); - const cat = JSON.parse(data) as Catalog; - this.setCatalog(cat); + const cat = await this.readAndAnalyzeCatalog(catalogPath); + await this.setCatalog(cat); } catch (err: unknown) { console.error('unable to read catalog file, reverting to default catalog', err); - this.setCatalog(defaultCatalog); + await this.setCatalog(defaultCatalog); } } - setCatalog(newCatalog: Catalog) { - // TODO(feloy): send message to frontend with new catalog + watchCatalogFile(path: string) { + const watcher = podmanDesktopApi.fs.createFileSystemWatcher(path); + watcher.onDidCreate(async () => { + try { + const cat = await this.readAndAnalyzeCatalog(path); + await this.setCatalog(cat); + } catch (err: unknown) { + console.error('unable to read created catalog file, continue using default catalog', err); + } + }); + watcher.onDidDelete(async () => { + console.log('user catalog file deleted, reverting to default catalog'); + await this.setCatalog(defaultCatalog); + }); + watcher.onDidChange(async () => { + try { + const cat = await this.readAndAnalyzeCatalog(path); + await this.setCatalog(cat); + } catch (err: unknown) { + console.error('unable to read modified catalog file, reverting to default catalog', err); + } + }); + } + + async readAndAnalyzeCatalog(path: string): Promise { + const data = await fs.promises.readFile(path, 'utf-8'); + return JSON.parse(data) as Catalog; + // TODO(feloy): check version, ... + } + + async setCatalog(newCatalog: Catalog) { this.catalog = newCatalog; + await this.webview.postMessage({ + id: MSG_NEW_CATALOG_STATE, + body: this.catalog, + }); } async openURL(url: string): Promise { @@ -69,27 +108,17 @@ export class StudioApiImpl implements StudioAPI { return 'pong'; } - async getRecentRecipes(): Promise { - return []; // no recent implementation for now - } - - async getCategories(): Promise { - return this.catalog.categories; - } - - async getRecipesByCategory(categoryId: string): Promise { - if (categoryId === RECENT_CATEGORY_ID) return this.getRecentRecipes(); - - return this.catalog.recipes.filter(recipe => recipe.categories.includes(categoryId)); + async getCatalog(): Promise { + return this.catalog; } - async getRecipeById(recipeId: string): Promise { + getRecipeById(recipeId: string): Recipe { const recipe = (this.catalog.recipes as Recipe[]).find(recipe => recipe.id === recipeId); if (recipe) return recipe; throw new Error('Not found'); } - async getModelById(modelId: string): Promise { + getModelById(modelId: string): ModelInfo { const model = this.catalog.models.find(m => modelId === m.id); if (!model) { throw new Error(`No model found having id ${modelId}`); @@ -97,23 +126,14 @@ export class StudioApiImpl implements StudioAPI { return model; } - async getModelsByIds(ids: string[]): Promise { - return this.catalog.models.filter(m => ids.includes(m.id)) ?? []; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async searchRecipes(_query: string): Promise { - return []; // todo: not implemented - } - async pullApplication(recipeId: string): Promise { console.log('StudioApiImpl pullApplication', recipeId); - const recipe: Recipe = await this.getRecipeById(recipeId); + const recipe: Recipe = this.getRecipeById(recipeId); console.log('StudioApiImpl recipe', recipe); // the user should have selected one model, we use the first one for the moment const modelId = recipe.models[0]; - const model = await this.getModelById(modelId); + const model = this.getModelById(modelId); // Do not wait for the pull application, run it separately this.applicationManager.pullApplication(recipe, model).catch((error: unknown) => console.warn(error)); diff --git a/packages/backend/src/studio.spec.ts b/packages/backend/src/studio.spec.ts index bbf0744b6..f456d46fd 100644 --- a/packages/backend/src/studio.spec.ts +++ b/packages/backend/src/studio.spec.ts @@ -40,6 +40,7 @@ vi.mock('@podman-desktop/api', async () => { webview: { html: '', onDidReceiveMessage: vi.fn(), + postMessage: vi.fn(), }, }), }, diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index 7ec079d86..4136d8cb0 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -93,7 +93,13 @@ export class Studio { const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry); const applicationManager = new ApplicationManager(gitManager, recipeStatusRegistry, this.#extensionContext); this.playgroundManager = new PlayGroundManager(this.#panel.webview); - this.studioApi = new StudioApiImpl(applicationManager, recipeStatusRegistry, taskRegistry, this.playgroundManager); + this.studioApi = new StudioApiImpl( + applicationManager, + recipeStatusRegistry, + taskRegistry, + this.playgroundManager, + this.#panel.webview, + ); await this.studioApi.loadCatalog(); // Register the instance this.rpcExtension.registerInstance(StudioApiImpl, this.studioApi); diff --git a/packages/frontend/src/lib/RecipesCard.svelte b/packages/frontend/src/lib/RecipesCard.svelte index 722678015..249b1627f 100644 --- a/packages/frontend/src/lib/RecipesCard.svelte +++ b/packages/frontend/src/lib/RecipesCard.svelte @@ -1,26 +1,19 @@ diff --git a/packages/frontend/src/pages/Model.spec.ts b/packages/frontend/src/pages/Model.spec.ts new file mode 100644 index 000000000..74ee77084 --- /dev/null +++ b/packages/frontend/src/pages/Model.spec.ts @@ -0,0 +1,39 @@ +import { vi, test, expect } from 'vitest'; +import { screen, render } from '@testing-library/svelte'; +import Model from './Model.svelte'; +import catalog from '../../../backend/src/ai-user-test.json'; + +const mocks = vi.hoisted(() => { + return { + getCatalogMock: vi.fn(), + }; +}); + +vi.mock('../utils/client', async () => { + return { + studioClient: { + getCatalog: mocks.getCatalogMock, + }, + rpcBrowser: { + subscribe: () => { + return { + unsubscribe: () => {}, + }; + }, + }, + }; +}); + +test('should display model information', async () => { + const model = catalog.models.find(m => m.id === 'model1'); + expect(model).not.toBeUndefined(); + + mocks.getCatalogMock.mockResolvedValue(catalog); + render(Model, { + modelId: 'model1', + }); + await new Promise(resolve => setTimeout(resolve, 200)); + + screen.getByText(model!.name); + screen.getByText(model!.description); +}); diff --git a/packages/frontend/src/pages/Model.svelte b/packages/frontend/src/pages/Model.svelte index 008cdc825..c9581c194 100644 --- a/packages/frontend/src/pages/Model.svelte +++ b/packages/frontend/src/pages/Model.svelte @@ -3,17 +3,12 @@ import NavPage from '/@/lib/NavPage.svelte'; import Tab from '/@/lib/Tab.svelte'; import Route from '/@/Route.svelte'; import MarkdownRenderer from '/@/lib/markdown/MarkdownRenderer.svelte'; -import type { ModelInfo } from '@shared/src/models/IModelInfo'; -import { studioClient } from '../utils/client'; -import { onMount } from 'svelte'; import ModelPlayground from './ModelPlayground.svelte'; +import { catalog } from '/@/stores/catalog'; export let modelId: string; -let model: ModelInfo | undefined = undefined; -onMount(async () => { - model = await studioClient.getModelById(modelId); -}) +$: model = $catalog.models.find(m => m.id === modelId); diff --git a/packages/frontend/src/pages/Recipe.spec.ts b/packages/frontend/src/pages/Recipe.spec.ts new file mode 100644 index 000000000..a12e52b3b --- /dev/null +++ b/packages/frontend/src/pages/Recipe.spec.ts @@ -0,0 +1,39 @@ +import { vi, test, expect } from 'vitest'; +import { screen, render } from '@testing-library/svelte'; +import catalog from '../../../backend/src/ai-user-test.json'; +import Recipe from './Recipe.svelte'; + +const mocks = vi.hoisted(() => { + return { + getCatalogMock: vi.fn(), + }; +}); + +vi.mock('../utils/client', async () => { + return { + studioClient: { + getCatalog: mocks.getCatalogMock, + }, + rpcBrowser: { + subscribe: () => { + return { + unsubscribe: () => {}, + }; + }, + }, + }; +}); + +test('should display recipe information', async () => { + const recipe = catalog.recipes.find(r => r.id === 'recipe 1'); + expect(recipe).not.toBeUndefined(); + + mocks.getCatalogMock.mockResolvedValue(catalog); + render(Recipe, { + recipeId: 'recipe 1', + }); + await new Promise(resolve => setTimeout(resolve, 200)); + + screen.getByText(recipe!.name); + screen.getByText(recipe!.readme); +}); diff --git a/packages/frontend/src/pages/Recipe.svelte b/packages/frontend/src/pages/Recipe.svelte index 333beb6be..635c64217 100644 --- a/packages/frontend/src/pages/Recipe.svelte +++ b/packages/frontend/src/pages/Recipe.svelte @@ -2,10 +2,8 @@ import NavPage from '/@/lib/NavPage.svelte'; import { onDestroy, onMount } from 'svelte'; import { studioClient } from '/@/utils/client'; -import type { Recipe as RecipeModel } from '@shared/models/IRecipe'; import Tab from '/@/lib/Tab.svelte'; import Route from '/@/Route.svelte'; -import type { Category } from '@shared/models/ICategory'; import Card from '/@/lib/Card.svelte'; import MarkdownRenderer from '/@/lib/markdown/MarkdownRenderer.svelte'; import Fa from 'svelte-fa'; @@ -14,14 +12,16 @@ import { faDownload, faRefresh } from '@fortawesome/free-solid-svg-icons'; import TasksProgress from '/@/lib/progress/TasksProgress.svelte'; import Button from '/@/lib/button/Button.svelte'; import { getDisplayName } from '/@/utils/versionControlUtils'; -import type { RecipeStatus } from '@shared/models/IRecipeStatus'; +import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; import { getIcon } from '/@/utils/categoriesUtils'; import RecipeModels from './RecipeModels.svelte'; +import { catalog } from '/@/stores/catalog'; export let recipeId: string; // The recipe model provided -let recipe: RecipeModel | undefined = undefined; +$: recipe = $catalog.recipes.find(r => r.id === recipeId); +$: categories = $catalog.categories; // By default, we are loading the recipe information let loading: boolean = true; @@ -29,14 +29,9 @@ let loading: boolean = true; // The pulling tasks let recipeStatus: RecipeStatus | undefined = undefined; -$: categories = [] as Category[] - let intervalId: ReturnType | undefined = undefined; onMount(async () => { - recipe = await studioClient.getRecipeById(recipeId); - categories = await studioClient.getCategories(); - // Pulling update intervalId = setInterval(async () => { recipeStatus = await studioClient.getPullingStatus(recipeId); diff --git a/packages/frontend/src/pages/RecipeModels.svelte b/packages/frontend/src/pages/RecipeModels.svelte index effd0e591..3d7078bb2 100644 --- a/packages/frontend/src/pages/RecipeModels.svelte +++ b/packages/frontend/src/pages/RecipeModels.svelte @@ -1,24 +1,18 @@ diff --git a/packages/frontend/src/stores/catalog.ts b/packages/frontend/src/stores/catalog.ts new file mode 100644 index 000000000..b58c9d7bf --- /dev/null +++ b/packages/frontend/src/stores/catalog.ts @@ -0,0 +1,24 @@ +import type { Readable } from 'svelte/store'; +import { readable } from 'svelte/store'; +import { MSG_NEW_CATALOG_STATE } from '@shared/Messages'; +import { rpcBrowser, studioClient } from '/@/utils/client'; +import type { Catalog } from '@shared/src/models/ICatalog'; + +const emptyCatalog = { + categories: [], + models: [], + recipes: [], +}; + +export const catalog: Readable = readable(emptyCatalog, set => { + const sub = rpcBrowser.subscribe(MSG_NEW_CATALOG_STATE, msg => { + set(msg); + }); + // Initialize the store manually + studioClient.getCatalog().then(state => { + set(state); + }); + return () => { + sub.unsubscribe(); + }; +}); diff --git a/packages/shared/Messages.ts b/packages/shared/Messages.ts index 433b1c142..ede982299 100644 --- a/packages/shared/Messages.ts +++ b/packages/shared/Messages.ts @@ -1 +1,2 @@ export const MSG_NEW_PLAYGROUND_QUERIES_STATE = 'new-playground-queries-state'; +export const MSG_NEW_CATALOG_STATE = 'new-catalog-state'; diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index 053f55dba..03edf8e3e 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -1,19 +1,12 @@ -import type { Recipe } from './models/IRecipe'; -import type { Category } from './models/ICategory'; import type { RecipeStatus } from './models/IRecipeStatus'; import type { ModelInfo } from './models/IModelInfo'; import type { Task } from './models/ITask'; import type { QueryState } from './models/IPlaygroundQueryState'; +import type { Catalog } from './models/ICatalog'; export abstract class StudioAPI { abstract ping(): Promise; - abstract getRecentRecipes(): Promise; - abstract getCategories(): Promise; - abstract getRecipesByCategory(categoryId: string): Promise; - abstract getRecipeById(recipeId: string): Promise; - abstract getModelById(modelId: string): Promise; - abstract getModelsByIds(ids: string[]): Promise; - abstract searchRecipes(query: string): Promise; + abstract getCatalog(): Promise; abstract getPullingStatus(recipeId: string): Promise; abstract pullApplication(recipeId: string): Promise; abstract openURL(url: string): Promise;