diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index 602b78239..916c93892 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -486,8 +486,8 @@ describe('getConfiguration', () => { localRepositoryRegistry, ); vi.spyOn(fs, 'existsSync').mockReturnValue(false); - expect(() => manager.getConfiguration('config', 'local')).toThrowError( - `The file located at ${path.join('local', 'config', CONFIG_FILENAME)} does not exist.`, + expect(() => manager.getConfiguration('local')).toThrowError( + `The file located at ${path.join('local', CONFIG_FILENAME)} does not exist.`, ); }); @@ -522,8 +522,8 @@ describe('getConfiguration', () => { }; mocks.parseYamlFileMock.mockReturnValue(aiConfig); - const result = manager.getConfiguration('config', 'local'); - expect(result.path).toEqual(path.join('local', 'config', CONFIG_FILENAME)); + const result = manager.getConfiguration('local'); + expect(result.path).toEqual(path.join('local', CONFIG_FILENAME)); expect(result.aiConfig).toEqual(aiConfig); }); }); @@ -532,6 +532,8 @@ describe('filterContainers', () => { test('return empty array when no container fit the system', () => { const aiConfig: AIConfig = { application: { + name: 'dummy-name', + type: 'dummy-type', containers: [ { name: 'container2', @@ -564,6 +566,8 @@ describe('filterContainers', () => { test('return one container when only one fit the system', () => { const aiConfig: AIConfig = { application: { + name: 'dummy-name', + type: 'dummy-type', containers: [ { name: 'container1', @@ -631,6 +635,8 @@ describe('filterContainers', () => { ]; const aiConfig: AIConfig = { application: { + name: 'dummy-name', + type: 'dummy-type', containers: containerConfig, }, }; @@ -1474,3 +1480,141 @@ describe('getImageTag', () => { expect(state[0].health).toEqual('healthy'); }); }); + +describe('getRecipeLocalFolder', () => { + const appUserDirectory = '/home/user/aistudio'; + const processCheckoutMock = vi.fn(); + + test('recipe with git repository should checkout', async () => { + const manager = new ApplicationManager( + appUserDirectory, + { + processCheckout: processCheckoutMock, + isGitInstalled: () => true, + } as unknown as GitManager, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, + {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, + ); + + const local = await manager.getRecipeLocalFolder({ + id: 'dummy-recipe-id', + name: 'dummy-recipe-name', + basedir: undefined, + repository: 'dummy-repository', + readme: 'dummy-readme', + description: '', + categories: [], + }, {id: 'dummy-model-id'} as unknown as ModelInfo); + + const targetDir = path.join(appUserDirectory, 'dummy-recipe-id'); + expect(processCheckoutMock).toHaveBeenCalledWith({ + repository: 'dummy-repository', + ref: undefined, + targetDirectory: targetDir, + }); + + expect(local).toBe(targetDir); + }); + + test('recipe with git repository and basedir should return joined path', async () => { + const manager = new ApplicationManager( + appUserDirectory, + { + processCheckout: processCheckoutMock, + isGitInstalled: () => true, + } as unknown as GitManager, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, + {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, + ); + + const local = await manager.getRecipeLocalFolder({ + id: 'dummy-recipe-id', + name: 'dummy-recipe-name', + basedir: 'relative-path', + repository: 'dummy-repository', + readme: 'dummy-readme', + description: '', + categories: [], + }, {id: 'dummy-model-id'} as unknown as ModelInfo); + + const targetDir = path.join(appUserDirectory, 'dummy-recipe-id', 'relative-path'); + expect(local).toBe(targetDir); + }); + + test('recipe without git repository should return basedir path', async () => { + const isAbsoluteMock = vi.spyOn(path, 'isAbsolute'); + isAbsoluteMock.mockReturnValue(true); + + const manager = new ApplicationManager( + appUserDirectory, + { + processCheckout: processCheckoutMock, + isGitInstalled: () => true, + } as unknown as GitManager, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, + {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, + ); + + const local = await manager.getRecipeLocalFolder({ + id: 'dummy-recipe-id', + name: 'dummy-recipe-name', + basedir: 'absolute-path', + repository: undefined, + readme: 'dummy-readme', + description: '', + categories: [], + }, {id: 'dummy-model-id'} as unknown as ModelInfo); + + expect(processCheckoutMock).not.toHaveBeenCalled(); + expect(isAbsoluteMock).toHaveBeenCalled(); + + expect(local).toBe('absolute-path'); + }); + + test('recipe without git repository and not absolute basedir should throw error', async () => { + const isAbsoluteMock = vi.spyOn(path, 'isAbsolute'); + isAbsoluteMock.mockReturnValue(false); + + const manager = new ApplicationManager( + appUserDirectory, + { + processCheckout: processCheckoutMock, + isGitInstalled: () => true, + } as unknown as GitManager, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, + {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, + ); + + expect(async () => { + await manager.getRecipeLocalFolder({ + id: 'dummy-recipe-id', + name: 'dummy-recipe-name', + basedir: 'absolute-path', + repository: undefined, + readme: 'dummy-readme', + description: '', + categories: [], + }, {id: 'dummy-model-id'} as unknown as ModelInfo); + }).rejects.toThrowError('recipe is malformed: either a repository or an absolute basedir must be defined.'); + }); +}); diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 95cfeb822..46e265a82 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -19,7 +19,7 @@ import type { Recipe } from '@shared/src/models/IRecipe'; import type { GitCloneInfo, GitManager } from './gitManager'; import fs from 'fs'; -import * as path from 'node:path'; +import path from 'node:path'; import { containerEngine, Disposable } from '@podman-desktop/api'; import type { BuildImageOptions, @@ -114,10 +114,14 @@ export class ApplicationManager extends Publisher implements return this.startApplication(recipe, model); } - async startApplication(recipe: Recipe, model: ModelInfo) { - // const recipeStatus = this.recipeStatusRegistry. - const startTime = performance.now(); - try { + /** + * The local folder is the path to the directory containing the CONFIG_FILENAME (ai-lab.yaml) + * + * @return the absolute path to the folder with the CONFIG_FILENAME file in it. + */ + async getRecipeLocalFolder(recipe: Recipe, model: ModelInfo): Promise { + // if the recipe has an associate git + if(recipe.repository) { const localFolder = path.join(this.appUserDirectory, recipe.id); // clone the recipe repository on the local folder @@ -131,26 +135,43 @@ export class ApplicationManager extends Publisher implements 'model-id': model.id, }); + // Get the absolute base folder + const source = recipe.basedir?path.join(localFolder, recipe.basedir):localFolder; this.localRepositories.register({ path: gitCloneInfo.targetDirectory, - sourcePath: path.join(gitCloneInfo.targetDirectory, recipe.basedir ?? ''), + sourcePath: source, labels: { 'recipe-id': recipe.id, }, }); + return source + } else if(recipe.basedir && path.isAbsolute(recipe.basedir)) { + return recipe.basedir; + } else { + throw new Error('recipe is malformed: either a repository or an absolute basedir must be defined.'); + } + } + + async startApplication(recipe: Recipe, model: ModelInfo): Promise { + // const recipeStatus = this.recipeStatusRegistry. + const startTime = performance.now(); + try { + // Get the absolute path to the CONFIG_FILENAME folder + const localFolder = await this.getRecipeLocalFolder(recipe, model); + // load and parse the recipe configuration file and filter containers based on architecture, gpu accelerator // and backend (that define which model supports) - const configAndFilteredContainers = this.getConfigAndFilterContainers(recipe.basedir, localFolder); + const configAndFilteredContainers = this.getConfigAndFilterContainers(localFolder); // get model by downloading it or retrieving locally - let modelPath = await this.modelsManager.requestDownloadModel(model, { + await this.modelsManager.requestDownloadModel(model, { 'recipe-id': recipe.id, 'model-id': model.id, }); // upload model to podman machine if user system is supported - modelPath = await this.modelsManager.uploadModelToPodmanMachine(model, { + const modelPath = await this.modelsManager.uploadModelToPodmanMachine(model, { 'recipe-id': recipe.id, 'model-id': model.id, }); @@ -515,7 +536,6 @@ export class ApplicationManager extends Publisher implements } getConfigAndFilterContainers( - recipeBaseDir: string | undefined, localFolder: string, labels?: { [key: string]: string }, ): AIContainers { @@ -525,7 +545,7 @@ export class ApplicationManager extends Publisher implements let aiConfigFile: AIConfigFile; try { // load and parse the recipe configuration file - aiConfigFile = this.getConfiguration(recipeBaseDir, localFolder); + aiConfigFile = this.getConfiguration(localFolder); } catch (e) { task.error = `Something went wrong while loading configuration: ${String(e)}.`; this.taskRegistry.updateTask(task); @@ -557,13 +577,8 @@ export class ApplicationManager extends Publisher implements ); } - getConfiguration(recipeBaseDir: string | undefined, localFolder: string): AIConfigFile { - let configFile: string; - if (recipeBaseDir !== undefined) { - configFile = path.join(localFolder, recipeBaseDir, CONFIG_FILENAME); - } else { - configFile = path.join(localFolder, CONFIG_FILENAME); - } + getConfiguration(localFolder: string): AIConfigFile { + let configFile: string = path.join(localFolder, CONFIG_FILENAME); if (!fs.existsSync(configFile)) { throw new Error(`The file located at ${configFile} does not exist.`); diff --git a/packages/backend/src/managers/catalogManager.ts b/packages/backend/src/managers/catalogManager.ts index 2ac211b74..c4c39791b 100644 --- a/packages/backend/src/managers/catalogManager.ts +++ b/packages/backend/src/managers/catalogManager.ts @@ -27,6 +27,8 @@ import { Disposable, type Webview } from '@podman-desktop/api'; import { JsonWatcher } from '../utils/JsonWatcher'; import { Publisher } from '../utils/Publisher'; import type { LocalModelImportInfo } from '@shared/src/models/ILocalModelInfo'; +import { type AIConfig, parseYamlFile } from '../models/AIConfig'; +import { goarch } from '../utils/arch'; export type catalogUpdateHandle = () => void; @@ -86,7 +88,10 @@ export class CatalogManager extends Publisher implements Dis ...defaultCatalog.models.filter(a => !sanitize.models.some(b => a.id === b.id)), ...sanitize.models, ] as ModelInfo[], - recipes: [...defaultCatalog.recipes.filter(a => !sanitize.recipes.some(b => a.id === b.id)), ...sanitize.recipes], + recipes: [ + ...defaultCatalog.recipes.filter(a => !sanitize.recipes.some(b => a.id === b.id)), + ...sanitize.recipes + ], categories: [ ...defaultCatalog.categories.filter(a => !sanitize.categories.some(b => a.id === b.id)), ...sanitize.categories, @@ -182,25 +187,79 @@ export class CatalogManager extends Publisher implements Dis return recipe; } - /** - * This method is used to imports user's local models. - * @param localModels the models to imports - */ - async importUserModels(localModels: LocalModelImportInfo[]): Promise { + async importLocalRecipe(configFile: string): Promise { + // Get the base dir of the ai-lab.yaml imported file + let basedir = path.dirname(configFile); + + // Ensure we are not trying to import an existing recipe + const exists = this.getRecipes().some(recipe => recipe.basedir === basedir); + if(exists) { + throw new Error('Cannot import an existing known recipe.'); + } + + // Let's try to parse the ai-lab.yaml provided + let aiConfig: AIConfig; + try { + aiConfig = parseYamlFile(configFile, goarch()); + } catch (err) { + console.error('Cannot load configure file.', err); + throw new Error(`Cannot load configuration file.`); + } + + // Let's check for a readme.md + let readme: string | undefined = undefined; + const readmePath = path.join(basedir, 'README.md'); + if(fs.existsSync(readmePath)) { + readme = await promises.readFile(readmePath, 'utf-8'); + } + + // Create the recipe from ai-lab content + const userRecipe: Recipe = { + id: configFile, + name: aiConfig.application.name, + description: aiConfig.application.description ?? 'no description', + categories: [aiConfig.application.type], + models: [], + basedir: path.dirname(configFile), + readme: readme ?? 'no readme', + } + + // append the new recipe + const content: ApplicationCatalog = await this.getUserCatalog(); + content.recipes.push(userRecipe); + + // overwrite the existing catalog + return this.saveUserCatalog(content); + // no need to notify as we have a file watcher + } + + private saveUserCatalog(content: ApplicationCatalog): Promise { + const userCatalogPath = this.getUserCatalogPath(); + return promises.writeFile(userCatalogPath, JSON.stringify(content, undefined, 2), 'utf-8'); + } + + private async getUserCatalog(): Promise { const userCatalogPath = this.getUserCatalogPath(); - let content: ApplicationCatalog; // check if we already have an existing user's catalog if (fs.existsSync(userCatalogPath)) { const raw = await promises.readFile(userCatalogPath, 'utf-8'); - content = this.sanitize(JSON.parse(raw)); + return this.sanitize(JSON.parse(raw)); } else { - content = { + return { recipes: [], models: [], categories: [], }; } + } + + /** + * This method is used to imports user's local models. + * @param localModels the models to imports + */ + async importUserModels(localModels: LocalModelImportInfo[]): Promise { + const content: ApplicationCatalog = await this.getUserCatalog(); // Transform local models into ModelInfo const models: ModelInfo[] = await Promise.all( @@ -226,7 +285,7 @@ export class CatalogManager extends Publisher implements Dis content.models.push(...models); // overwrite the existing catalog - return promises.writeFile(userCatalogPath, JSON.stringify(content, undefined, 2), 'utf-8'); + return this.saveUserCatalog(content); } /** diff --git a/packages/backend/src/models/AIConfig.ts b/packages/backend/src/models/AIConfig.ts index f09a50d7e..880d4e3c2 100644 --- a/packages/backend/src/models/AIConfig.ts +++ b/packages/backend/src/models/AIConfig.ts @@ -31,6 +31,9 @@ export interface ContainerConfig { } export interface AIConfig { application: { + name: string; + type: string; + description?: string; containers: ContainerConfig[]; }; } @@ -66,14 +69,30 @@ export function parseYamlFile(filepath: string, defaultArch: string): AIConfig { throw new Error('AIConfig has bad formatting: application does not have valid container property'); } + if(!('name' in application) || typeof application['name'] !== 'string') { + throw new Error('name property is missing or malformed'); + } + + if(!('type' in application) || typeof application['type'] !== 'string') { + throw new Error('type property is missing or malformed'); + } + if (!Array.isArray(application['containers'])) { throw new Error('AIConfig has bad formatting: containers property must be an array.'); } + let description: string | undefined = undefined; + if('description' in application && typeof application['description'] === 'string') { + description = application['description'] + } + const containers: unknown[] = application['containers']; return { application: { + name: application['name'], + type: application['type'], + description: description, containers: containers.map(container => { if (!container || typeof container !== 'object') throw new Error('containers array malformed'); diff --git a/packages/backend/src/registries/LocalRepositoryRegistry.ts b/packages/backend/src/registries/LocalRepositoryRegistry.ts index f9db5a2b7..c262b8ddc 100644 --- a/packages/backend/src/registries/LocalRepositoryRegistry.ts +++ b/packages/backend/src/registries/LocalRepositoryRegistry.ts @@ -67,8 +67,9 @@ export class LocalRepositoryRegistry extends Publisher { private loadLocalRecipeRepositories(recipes: Recipe[]): void { recipes.forEach(recipe => { + const recipeFolder = path.join(this.appUserDirectory, recipe.id); - if (fs.existsSync(recipeFolder)) { + if(recipe.repository && fs.existsSync(recipeFolder)) { this.register({ path: recipeFolder, sourcePath: path.join(recipeFolder, recipe.basedir ?? ''), @@ -76,6 +77,14 @@ export class LocalRepositoryRegistry extends Publisher { 'recipe-id': recipe.id, }, }); + } else if(recipe.basedir && path.isAbsolute(recipe.basedir)) { + this.register({ + path: recipe.basedir, + sourcePath: recipe.basedir, + labels: { + 'recipe-id': recipe.id, + }, + }); } }); } diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index e00e85fbc..d16290281 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -463,6 +463,10 @@ export class StudioApiImpl implements StudioAPI { return this.catalogManager.importUserModels(models); } + importLocalRecipe(configFile: string): Promise { + return this.catalogManager.importLocalRecipe(configFile); + } + async checkInvalidModels(models: string[]): Promise { const invalidPaths: string[] = []; const catalogModels = await this.getModelsInfo(); diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 7e42c2ef9..e5f19f67e 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -20,6 +20,7 @@ import Playgrounds from './pages/Playgrounds.svelte'; import Playground from './pages/Playground.svelte'; import PlaygroundCreate from './pages/PlaygroundCreate.svelte'; import ImportModels from './pages/ImportModels.svelte'; +import ImportRecipe from '/@/pages/ImportRecipe.svelte'; router.mode.hash(); @@ -44,8 +45,13 @@ onMount(() => { - - + + + + + + + diff --git a/packages/frontend/src/pages/ImportRecipe.svelte b/packages/frontend/src/pages/ImportRecipe.svelte new file mode 100644 index 000000000..872aa94df --- /dev/null +++ b/packages/frontend/src/pages/ImportRecipe.svelte @@ -0,0 +1,165 @@ + + + + + + + +
+ +
+ {#if error !== undefined} + + {/if} +
+ + +
+
+ + + + + + +
+ +
+ +
+ + + + + + + + + +
+ + + + +
+ +
+ + +
+
+
+ + + + + + + + + + + +
+
+
+
+
+
diff --git a/packages/frontend/src/pages/Recipe.svelte b/packages/frontend/src/pages/Recipe.svelte index 4517d644d..d0ddc2480 100644 --- a/packages/frontend/src/pages/Recipe.svelte +++ b/packages/frontend/src/pages/Recipe.svelte @@ -14,6 +14,9 @@ import type { ContainerConnectionInfo } from '@shared/src/models/IContainerConne import ContainerConnectionStatusInfo from '../lib/notification/ContainerConnectionStatusInfo.svelte'; import { modelsInfo } from '../stores/modelsInfo'; import { checkContainerConnectionStatus } from '../utils/connectionUtils'; +import Button from '/@/lib/button/Button.svelte'; +import { faFileImport } from '@fortawesome/free-solid-svg-icons'; +import Modal from '/@/lib/Modal.svelte'; export let recipeId: string; diff --git a/packages/frontend/src/pages/RecipeModels.svelte b/packages/frontend/src/pages/RecipeModels.svelte index 60a93c09e..60aa14c7b 100644 --- a/packages/frontend/src/pages/RecipeModels.svelte +++ b/packages/frontend/src/pages/RecipeModels.svelte @@ -7,20 +7,14 @@ import ModelColumnRecipeSelection from '../lib/table/model/ModelColumnRecipeSele import ModelColumnRecipeRecommended from '../lib/table/model/ModelColumnRecipeRecommended.svelte'; import type { RecipeModelInfo } from '../models/RecipeModelInfo'; import ModelColumnIcon from '/@/lib/table/model/ModelColumnIcon.svelte'; +import { onMount } from 'svelte'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; export let modelsIds: string[] | undefined; export let selectedModelId: string; export let setSelectedModel: (modelId: string) => void; -$: models = $catalog.models - .filter(m => modelsIds?.includes(m.id)) - .map((m, i) => { - return { - ...m, - recommended: i === 0, - inUse: m.id === selectedModelId, - } as RecipeModelInfo; - }); +let models: RecipeModelInfo[] = []; const columns: Column[] = [ new Column('', { width: '20px', renderer: ModelColumnRecipeSelection }), @@ -32,7 +26,28 @@ const row = new Row({}); function setModelToUse(selected: RecipeModelInfo) { setSelectedModel(selected.id); + // update inUse models + models = models.map(model => ({...model, inUse: model.id === selected.id})); } + +onMount(() => { + return catalog.subscribe((catalog) => { + let mModels: ModelInfo[]; + // If we do not have any models id provided, we just provide all + if(modelsIds === undefined || modelsIds.length === 0) { + mModels = catalog.models; + } else { + mModels= catalog.models.filter(m => modelsIds?.includes(m.id)); + } + // Map ModelInfo to RecipeModelInfo + models = mModels.map((m, i) => ({ + ...m, + recommended: i === 0, + inUse: m.id === selectedModelId, + }) as RecipeModelInfo + ); + }); +}) {#if models} diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index 3ac553e8c..4c7480aa4 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -184,6 +184,12 @@ export abstract class StudioAPI { */ abstract checkInvalidModels(models: string[]): Promise; + /** + * Given a recipe config file, import it to the user's catalog + * @param configFile the ai-lab.yaml file to import + */ + abstract importLocalRecipe(configFile: string): Promise; + /** * Copy the provided content to the user clipboard * @param content diff --git a/packages/shared/src/models/IRecipe.ts b/packages/shared/src/models/IRecipe.ts index 78af20709..d25348334 100644 --- a/packages/shared/src/models/IRecipe.ts +++ b/packages/shared/src/models/IRecipe.ts @@ -22,9 +22,10 @@ export interface Recipe { categories: string[]; description: string; icon?: string; - repository: string; + repository?: string; ref?: string; readme: string; + // basedir is relative to the local folder if a repository is associated otherwise absolute path basedir?: string; models?: string[]; }