Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
36 changes: 27 additions & 9 deletions packages/backend/src/assets/ai.json

Large diffs are not rendered by default.

19 changes: 6 additions & 13 deletions packages/backend/src/managers/catalogManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,10 @@ import { Publisher } from '../utils/Publisher';
import type { LocalModelImportInfo } from '@shared/src/models/ILocalModelInfo';
import { InferenceType } from '@shared/src/models/IInference';
import { CatalogFormat, hasCatalogWrongFormat, merge, sanitize } from '../utils/catalogUtils';
import type { FilterRecipesResult, RecipeFilters } from '@shared/src/models/FilterRecipesResult';

export const USER_CATALOG = 'user-catalog.json';

export type CatalogFilterKey = 'languages' | 'tools' | 'frameworks';

export type RecipeFilters = {
[key in CatalogFilterKey]?: string[];
};

export interface FilterRecipesResult {
filters: RecipeFilters;
choices: RecipeFilters;
result: Recipe[];
}

export class CatalogManager extends Publisher<ApplicationCatalog> implements Disposable {
private readonly _onUpdate = new EventEmitter<ApplicationCatalog>();
readonly onUpdate: Event<ApplicationCatalog> = this._onUpdate.event;
Expand Down Expand Up @@ -330,7 +319,11 @@ export class CatalogManager extends Publisher<ApplicationCatalog> implements Dis
delete subfilters.tools;
choices.tools = this.filterRecipes(subfilters).choices.tools;
} else {
choices.tools = result.map(r => r.backend).filter(b => b !== undefined);
choices.tools = result
.map(r => r.backend)
.filter(b => b !== undefined)
.filter((value, index, array) => array.indexOf(value) === index)
.sort((a, b) => a.localeCompare(b));
}

if ('frameworks' in filters) {
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/studio-api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type { PodmanConnection } from './managers/podmanConnection';
import type { RecipePullOptions } from '@shared/src/models/IRecipe';
import type { ContainerProviderConnection } from '@podman-desktop/api';
import type { NavigationRegistry } from './registries/NavigationRegistry';
import type { FilterRecipesResult, RecipeFilters } from '@shared/src/models/FilterRecipesResult';

interface PortQuickPickItem extends podmanDesktopApi.QuickPickItem {
port: number;
Expand Down Expand Up @@ -250,6 +251,10 @@ export class StudioApiImpl implements StudioAPI {
return this.catalogManager.getCatalog();
}

async filterRecipes(filters: RecipeFilters): Promise<FilterRecipesResult> {
return this.catalogManager.filterRecipes(filters);
}

async requestRemoveLocalModel(modelId: string): Promise<void> {
const modelInfo = this.modelsManager.getLocalModelInfo(modelId);

Expand Down
165 changes: 130 additions & 35 deletions packages/frontend/src/pages/Recipes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
***********************************************************************/

import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { beforeEach, expect, test, vi } from 'vitest';
import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog';
import * as catalogStore from '/@/stores/catalog';
import { readable } from 'svelte/store';
import Recipes from '/@/pages/Recipes.svelte';
import { studioClient } from '../utils/client';

vi.mock('/@/stores/catalog', async () => {
return {
Expand All @@ -31,7 +32,9 @@ vi.mock('/@/stores/catalog', async () => {
});

vi.mock('../utils/client', async () => ({
studioClient: {},
studioClient: {
filterRecipes: vi.fn(),
},
}));

vi.mock('../stores/localRepositories', () => ({
Expand All @@ -43,50 +46,142 @@ vi.mock('../stores/localRepositories', () => ({
},
}));

const recipes = [
{
id: 'recipe1',
name: 'Recipe 1',
recommended: ['model1'],
categories: [],
description: 'Recipe 1',
readme: '',
repository: 'https://recipe-1',
},
{
id: 'recipe2',
name: 'Recipe 2',
recommended: ['model2'],
categories: ['dummy-category'],
description: 'Recipe 2',
readme: '',
repository: 'https://recipe-2',
},
];

const catalog: ApplicationCatalog = {
recipes: recipes,
models: [],
categories: [
{
id: 'dummy-category',
name: 'Dummy category',
},
],
};

beforeEach(() => {
vi.resetAllMocks();
const catalog: ApplicationCatalog = {
recipes: [
{
id: 'recipe1',
name: 'Recipe 1',
recommended: ['model1'],
categories: [],
description: 'Recipe 1',
readme: '',
repository: 'https://recipe-1',
},
{
id: 'recipe2',
name: 'Recipe 2',
recommended: ['model2'],
categories: ['dummy-category'],
description: 'Recipe 2',
readme: '',
repository: 'https://recipe-2',
},
],
models: [],
categories: [
{
id: 'dummy-category',
name: 'Dummy category',
},
],
};

vi.mocked(catalogStore).catalog = readable(catalog);
vi.mocked(studioClient).filterRecipes.mockResolvedValue({
result: recipes,
filters: {},
choices: {},
});
});

test('recipe without category should be visible', async () => {
render(Recipes);

const text = screen.getAllByText('Recipe 1');
expect(text.length).toBeGreaterThan(0);
await vi.waitFor(() => {
const text = screen.getAllByText('Recipe 1');
expect(text.length).toBeGreaterThan(0);
});
});

test('recipe with category should be visible', async () => {
render(Recipes);

const text = screen.getAllByText('Recipe 2');
expect(text.length).toBeGreaterThan(0);
await vi.waitFor(() => {
const text = screen.getAllByText('Recipe 2');
expect(text.length).toBeGreaterThan(0);
});
});

test('filters returned in choices + (no filter) are displayed', async () => {
vi.mocked(studioClient).filterRecipes.mockResolvedValue({
result: recipes,
filters: {},
choices: {
tools: ['tool1', 'tool2'],
languages: ['lang1', 'lang2'],
frameworks: ['fw1', 'fw2'],
},
});

render(Recipes);

await vi.waitFor(() => {
const text = screen.getAllByText('Recipe 1');
expect(text.length).toBeGreaterThan(0);
});

const tests = [
{ category: 'Tools', choices: ['(no filter)', 'tool1', 'tool2'] },
{ category: 'Frameworks', choices: ['(no filter)', 'fw1', 'fw2'] },
{ category: 'Languages', choices: ['(no filter)', 'lang1', 'lang2'] },
];

for (const test of tests) {
const dropdownLabel = screen.getByLabelText(test.category);
expect(dropdownLabel).toBeInTheDocument();
await fireEvent.click(dropdownLabel);

await vi.waitFor(() => {
for (const choice of test.choices) {
const text = screen.getAllByText(choice);
expect(text.length).toBeGreaterThan(0);
}
});
}
});

test('filterRecipes is called with selected filters', async () => {
vi.mocked(studioClient).filterRecipes.mockResolvedValue({
result: recipes,
filters: {},
choices: {
tools: ['tool1', 'tool2'],
languages: ['lang1', 'lang2'],
frameworks: ['fw1', 'fw2'],
},
});

render(Recipes);

await vi.waitFor(() => {
const text = screen.getAllByText('Recipe 1');
expect(text.length).toBeGreaterThan(0);
});

const selectedFilters = [
{ category: 'Tools', filter: 'tool1' },
{ category: 'Languages', filter: 'lang2' },
];

for (const selectedFilter of selectedFilters) {
const dropdownLabel = screen.getByLabelText(selectedFilter.category);
expect(dropdownLabel).toBeInTheDocument();
await fireEvent.click(dropdownLabel);

await vi.waitFor(async () => {
const text = screen.getByText(selectedFilter.filter);
expect(text).toBeInTheDocument();
await fireEvent.click(text);
});
}

expect(studioClient.filterRecipes).toHaveBeenCalledWith({
tools: ['tool1'],
languages: ['lang2'],
});
});
Loading