Skip to content

Commit

Permalink
Merge pull request #92 from projectatomic/feat/reactive-catalog
Browse files Browse the repository at this point in the history
push the catalog to the frontend and use a store on frontend
  • Loading branch information
feloy authored Jan 19, 2024
2 parents a0fda99 + 2026b9b commit 0e961e5
Show file tree
Hide file tree
Showing 14 changed files with 201 additions and 130 deletions.
42 changes: 10 additions & 32 deletions packages/backend/src/studio-api-impl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
88 changes: 54 additions & 34 deletions packages/backend/src/studio-api-impl.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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 = {
Expand All @@ -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<Catalog> {
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<boolean> {
Expand All @@ -69,51 +108,32 @@ export class StudioApiImpl implements StudioAPI {
return 'pong';
}

async getRecentRecipes(): Promise<Recipe[]> {
return []; // no recent implementation for now
}

async getCategories(): Promise<Category[]> {
return this.catalog.categories;
}

async getRecipesByCategory(categoryId: string): Promise<Recipe[]> {
if (categoryId === RECENT_CATEGORY_ID) return this.getRecentRecipes();

return this.catalog.recipes.filter(recipe => recipe.categories.includes(categoryId));
async getCatalog(): Promise<Catalog> {
return this.catalog;
}

async getRecipeById(recipeId: string): Promise<Recipe> {
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<ModelInfo> {
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}`);
}
return model;
}

async getModelsByIds(ids: string[]): Promise<ModelInfo[]> {
return this.catalog.models.filter(m => ids.includes(m.id)) ?? [];
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async searchRecipes(_query: string): Promise<Recipe[]> {
return []; // todo: not implemented
}

async pullApplication(recipeId: string): Promise<void> {
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));
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/studio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vi.mock('@podman-desktop/api', async () => {
webview: {
html: '',
onDidReceiveMessage: vi.fn(),
postMessage: vi.fn(),
},
}),
},
Expand Down
8 changes: 7 additions & 1 deletion packages/backend/src/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>(StudioApiImpl, this.studioApi);
Expand Down
15 changes: 4 additions & 11 deletions packages/frontend/src/lib/RecipesCard.svelte
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
<script lang="ts">
import Card from '/@/lib/Card.svelte';
import type { Recipe } from '@shared/models/IRecipe';
import { getIcon } from '/@/utils/categoriesUtils';
import { onMount } from 'svelte';
import { studioClient } from '/@/utils/client';
import type { Category } from '@shared/models/ICategory';
import type { Category } from '@shared/src/models/ICategory';
import { catalog } from '/@/stores/catalog';
export let category: Category
$: recipes = [] as Recipe[];
$: categories = [] as Category[];
$: categories = $catalog.categories;
$: recipes = $catalog.recipes.filter(r => r.categories.includes(category.id)).map(r => ({...r, icon: category.id}));
export let primaryBackground: string = "bg-charcoal-800"
export let secondaryBackground: string = "bg-charcoal-700"
export let displayCategory: boolean = true
export let displayDescription: boolean = true
onMount(async () => {
recipes = (await studioClient.getRecipesByCategory(category.id)).map((recipe) => ({...recipe, icon: category.id}))
categories = await studioClient.getCategories();
})
</script>

<Card title="{category.name}" classes="{primaryBackground} {$$props.class} text-xl font-medium mt-4">
Expand Down
39 changes: 39 additions & 0 deletions packages/frontend/src/pages/Model.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
9 changes: 2 additions & 7 deletions packages/frontend/src/pages/Model.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
</script>

<NavPage title="{model?.name || ''}" searchEnabled="{false}" loading="{model === undefined}">
Expand Down
39 changes: 39 additions & 0 deletions packages/frontend/src/pages/Recipe.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading

0 comments on commit 0e961e5

Please sign in to comment.