Skip to content
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

push the catalog to the frontend and use a store on frontend #92

Merged
merged 4 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 * 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 @@
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 @@

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);

Check warning on line 45 in packages/backend/src/studio-api-impl.ts

View workflow job for this annotation

GitHub Actions / linter, formatters and unit tests / windows-2022

Strings must use singlequote

Check warning on line 45 in packages/backend/src/studio-api-impl.ts

View workflow job for this annotation

GitHub Actions / linter, formatters and unit tests / ubuntu-22.04

Strings must use singlequote

Check warning on line 45 in packages/backend/src/studio-api-impl.ts

View workflow job for this annotation

GitHub Actions / linter, formatters and unit tests / macos-12

Strings must use singlequote
}

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 @@
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