diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..94f480de9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml new file mode 100644 index 000000000..71f8f4ecc --- /dev/null +++ b/.github/workflows/pr-check.yaml @@ -0,0 +1,87 @@ +# +# Copyright (C) 2024 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +name: pr-check + +on: [pull_request] + +jobs: + lint-format-unit: + name: linter, formatters and unit tests / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + os: [windows-2022, ubuntu-22.04, macos-12] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Get yarn cache directory path (Windows) + if: ${{ matrix.os=='windows-2022' }} + id: yarn-cache-dir-path-windows + run: echo "dir=$(yarn cache dir)" >> ${env:GITHUB_OUTPUT} + + - name: Get yarn cache directory path (mac/Linux) + if: ${{ matrix.os=='ubuntu-22.04'}} + id: yarn-cache-dir-path-unix + run: echo "dir=$(yarn cache dir)" >> ${GITHUB_OUTPUT} + + - uses: actions/cache@v3 + if: ${{ matrix.os=='windows-2022' }} + id: yarn-cache-windows + with: + path: ${{ steps.yarn-cache-dir-path-windows.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - uses: actions/cache@v3 + if: ${{ matrix.os=='ubuntu-22.04'}} + id: yarn-cache-unix + with: + path: ${{ steps.yarn-cache-dir-path-unix.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Execute yarn + run: yarn --frozen-lockfile --network-timeout 180000 + + - name: Run linter + run: yarn lint:check + + - name: Run formatter + run: yarn format:check + + - name: Run unit tests + run: yarn test:unit + + - name: Run typecheck + run: yarn typecheck + + # Check we don't have changes in git + - name: Check no changes in git + if: ${{ matrix.os=='ubuntu-22.04'}} + run: | + if ! git diff --exit-code; then + echo "Found changes in git" + exit 1 + fi diff --git a/package.json b/package.json index fe94fdbbf..632f3078f 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,17 @@ "format:fix": "prettier --write \"**/src/**/*.ts\"", "lint:check": "eslint . --ext js,ts,tsx", "lint:fix": "eslint . --fix --ext js,ts,tsx", - "test:backend": "vitest run -r packages/backend --passWithNoTests --coverage" + "test:backend": "vitest run -r packages/backend --passWithNoTests --coverage", + "test:frontend": "vitest run -r packages/frontend --passWithNoTests --coverage", + "test:shared": "vitest run -r packages/shared --passWithNoTests --coverage", + "test:unit": "npm run test:backend && npm run test:shared && npm run test:frontend", + "typecheck:shared": "tsc --noEmit --project packages/shared", + "typecheck:frontend": "tsc --noEmit --project packages/frontend", + "typecheck:backend": "tsc --noEmit --project packages/backend", + "typecheck": "npm run typecheck:shared && npm run typecheck:frontend && npm run typecheck:backend" }, "devDependencies": { + "@podman-desktop/api": "1.6.4", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "@vitest/coverage-v8": "^1.1.0", diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index a887a9d22..1fc93d1ec 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -1,22 +1,23 @@ -import { Recipe } from '@shared/models/IRecipe'; +import type { Recipe } from '@shared/src/models/IRecipe'; import { arch } from 'node:os'; -import { GitManager } from './gitManager'; +import type { GitManager } from './gitManager'; import os from 'os'; import fs from 'fs'; import * as https from 'node:https'; import * as path from 'node:path'; -import { containerEngine, ExtensionContext, provider } from '@podman-desktop/api'; -import { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; -import { AIConfig, parseYaml } from '../models/AIConfig'; -import { Task } from '@shared/models/ITask'; +import { containerEngine, type ExtensionContext, provider } from '@podman-desktop/api'; +import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; +import type { AIConfig } from '../models/AIConfig'; +import { parseYaml } from '../models/AIConfig'; +import type { Task } from '@shared/src/models/ITask'; import { RecipeStatusUtils } from '../utils/recipeStatusUtils'; import { getParentDirectory } from '../utils/pathUtils'; -import type { LocalModelInfo } from '@shared/models/ILocalModelInfo'; -import { ModelInfo } from '@shared/models/IModelInfo'; +import type { LocalModelInfo } from '@shared/src/models/ILocalModelInfo'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; // TODO: Need to be configured export const AI_STUDIO_FOLDER = path.join('podman-desktop', 'ai-studio'); -export const CONFIG_FILENAME = "ai-studio.yaml"; +export const CONFIG_FILENAME = 'ai-studio.yaml'; interface DownloadModelResult { result: 'ok' | 'failed'; @@ -26,7 +27,11 @@ interface DownloadModelResult { export class ApplicationManager { readonly homeDirectory: string; // todo: make configurable - constructor(private git: GitManager, private recipeStatusRegistry: RecipeStatusRegistry, private extensionContext: ExtensionContext) { + constructor( + private git: GitManager, + private recipeStatusRegistry: RecipeStatusRegistry, + private extensionContext: ExtensionContext, + ) { this.homeDirectory = os.homedir(); } @@ -42,19 +47,19 @@ export class ApplicationManager { name: 'Checkout repository', state: 'loading', labels: { - 'git': 'checkout', + git: 'checkout', }, - } + }; taskUtil.setTask(checkoutTask); // We might already have the repository cloned - if(fs.existsSync(localFolder) && fs.statSync(localFolder).isDirectory()) { + if (fs.existsSync(localFolder) && fs.statSync(localFolder).isDirectory()) { // Update checkout state checkoutTask.name = 'Checkout repository (cached).'; checkoutTask.state = 'success'; } else { // Create folder - fs.mkdirSync(localFolder, {recursive: true}); + fs.mkdirSync(localFolder, { recursive: true }); // Clone the repository console.log(`Cloning repository ${recipe.repository} in ${localFolder}.`); @@ -71,27 +76,27 @@ export class ApplicationManager { id: 'loading-config', name: 'Loading configuration', state: 'loading', - } + }; taskUtil.setTask(loadingConfiguration); let configFile: string; - if(recipe.config !== undefined) { + if (recipe.config !== undefined) { configFile = path.join(localFolder, recipe.config); } else { configFile = path.join(localFolder, CONFIG_FILENAME); } - if(!fs.existsSync(configFile)) { + if (!fs.existsSync(configFile)) { loadingConfiguration.state = 'error'; taskUtil.setTask(loadingConfiguration); throw new Error(`The file located at ${configFile} does not exist.`); } // If the user configured the config as a directory we check for "ai-studio.yaml" inside. - if(fs.statSync(configFile).isDirectory()) { + if (fs.statSync(configFile).isDirectory()) { const tmpPath = path.join(configFile, CONFIG_FILENAME); // If it has the ai-studio.yaml we use it. - if(fs.existsSync(tmpPath)) { + if (fs.existsSync(tmpPath)) { configFile = tmpPath; } } @@ -109,7 +114,7 @@ export class ApplicationManager { throw new Error('Cannot load configuration file.'); } - // Mark as success + // Mark as success. loadingConfiguration.state = 'success'; taskUtil.setTask(loadingConfiguration); @@ -118,8 +123,9 @@ export class ApplicationManager { const connection = connections[0]; // Filter the containers based on architecture - const filteredContainers = aiConfig.application.containers - .filter((container) => container.arch === undefined || container.arch === arch()) + const filteredContainers = aiConfig.application.containers.filter( + container => container.arch === undefined || container.arch === arch(), + ); // Download model taskUtil.setTask({ @@ -127,38 +133,37 @@ export class ApplicationManager { state: 'loading', name: `Downloading model ${model.name}`, labels: { - "model-pulling": model.id, - } + 'model-pulling': model.id, + }, }); - await this.downloadModelMain(model.id, model.url, taskUtil) - + await this.downloadModelMain(model.id, model.url, taskUtil); - filteredContainers.forEach((container) => { + filteredContainers.forEach(container => { taskUtil.setTask({ id: container.name, state: 'loading', name: `Building ${container.name}`, - }) + }); }); // Promise all the build images return Promise.all( - filteredContainers.map((container) => - { - // We use the parent directory of our configFile as the rootdir, then we append the contextDir provided - const context = path.join(getParentDirectory(configFile), container.contextdir); - console.log(`Application Manager using context ${context} for container ${container.name}`); - - // Ensure the context provided exist otherwise throw an Error - if(!fs.existsSync(context)) { - console.error('The context provided does not exist.'); - taskUtil.setTaskState(container.name, 'error'); - throw new Error('Context configured does not exist.'); - } + filteredContainers.map(container => { + // We use the parent directory of our configFile as the rootdir, then we append the contextDir provided + const context = path.join(getParentDirectory(configFile), container.contextdir); + console.log(`Application Manager using context ${context} for container ${container.name}`); + + // Ensure the context provided exist otherwise throw an Error + if (!fs.existsSync(context)) { + console.error('The context provided does not exist.'); + taskUtil.setTaskState(container.name, 'error'); + throw new Error('Context configured does not exist.'); + } - let isErrored = false; - return containerEngine.buildImage( + let isErrored = false; + return containerEngine + .buildImage( context, container.containerfile, `${container.name}:latest`, @@ -167,24 +172,23 @@ export class ApplicationManager { (event, data) => { // todo: do something with the event if (event === 'error' || (event === 'finish' && data !== '')) { - console.error(`Something went wrong while building the image: `, data); + console.error('Something went wrong while building the image: ', data); taskUtil.setTaskState(container.name, 'error'); isErrored = true; } if (event === 'finish' && !isErrored) { - taskUtil.setTaskState(container.name, 'success'); + taskUtil.setTaskState(container.name, 'success'); } - } - ).catch(err => { - console.error(`Something went wrong while building the image: `, err); + }, + ) + .catch((err: unknown) => { + console.error('Something went wrong while building the image: ', err); taskUtil.setTaskState(container.name, 'error'); }); - } - ) - ) + }), + ); } - downloadModelMain(modelId: string, url: string, taskUtil: RecipeStatusUtils, destFileName?: string): Promise { return new Promise((resolve, reject) => { const downloadCallback = (result: DownloadModelResult) => { @@ -193,33 +197,39 @@ export class ApplicationManager { resolve(''); } else { taskUtil.setTaskState(modelId, 'error'); - reject(result.error) + reject(result.error); } - } + }; - if(fs.existsSync(destFileName)) { + if (fs.existsSync(destFileName)) { taskUtil.setTaskState(modelId, 'success'); taskUtil.setTaskProgress(modelId, 100); return; } - this.downloadModel(modelId, url, taskUtil, downloadCallback, destFileName) - }) + this.downloadModel(modelId, url, taskUtil, downloadCallback, destFileName); + }); } - private downloadModel(modelId: string, url: string, taskUtil: RecipeStatusUtils, callback: (message: DownloadModelResult) => void, destFileName?: string) { + private downloadModel( + modelId: string, + url: string, + taskUtil: RecipeStatusUtils, + callback: (message: DownloadModelResult) => void, + destFileName?: string, + ) { const destDir = path.join(this.homeDirectory, AI_STUDIO_FOLDER, 'models', modelId); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } if (!destFileName) { - destFileName = path.basename(url); + destFileName = path.basename(url); } const destFile = path.resolve(destDir, destFileName); const file = fs.createWriteStream(destFile); let totalFileSize = 0; let progress = 0; - https.get(url, (resp) => { + https.get(url, resp => { if (resp.headers.location) { this.downloadModel(modelId, resp.headers.location, taskUtil, callback, destFileName); return; @@ -229,9 +239,9 @@ export class ApplicationManager { } } - resp.on('data', (chunk) => { + resp.on('data', chunk => { progress += chunk.length; - const progressValue = progress * 100 / totalFileSize; + const progressValue = (progress * 100) / totalFileSize; taskUtil.setTaskProgress(modelId, progressValue); @@ -239,19 +249,19 @@ export class ApplicationManager { //this.sendProgress(progressValue); if (progressValue === 100) { callback({ - result: 'ok' + result: 'ok', }); } }); file.on('finish', () => { file.close(); }); - file.on('error', (e) => { + file.on('error', e => { callback({ result: 'failed', error: e.message, }); - }) + }); resp.pipe(file); }); } @@ -271,7 +281,7 @@ export class ApplicationManager { result.push({ id: d.name, file: modelEntries[0], - }) + }); } return result; } diff --git a/packages/backend/src/managers/gitManager.ts b/packages/backend/src/managers/gitManager.ts index e4b5a3ee8..a5004acb3 100644 --- a/packages/backend/src/managers/gitManager.ts +++ b/packages/backend/src/managers/gitManager.ts @@ -1,13 +1,12 @@ -import simpleGit, { type SimpleGit } from "simple-git"; - +import simpleGit, { type SimpleGit } from 'simple-git'; export class GitManager { - private readonly simpleGit: SimpleGit - constructor() { - this.simpleGit = simpleGit(); - } + private readonly simpleGit: SimpleGit; + constructor() { + this.simpleGit = simpleGit(); + } - async cloneRepository(repository: string, targetDirectory: string) { - return this.simpleGit.clone(repository, targetDirectory); - } + async cloneRepository(repository: string, targetDirectory: string) { + return this.simpleGit.clone(repository, targetDirectory); + } } diff --git a/packages/backend/src/models/AIConfig.ts b/packages/backend/src/models/AIConfig.ts index 1f1fd92c7..097f5230d 100644 --- a/packages/backend/src/models/AIConfig.ts +++ b/packages/backend/src/models/AIConfig.ts @@ -9,38 +9,35 @@ export interface ContainerConfig { } export interface AIConfig { application: { - containers: ContainerConfig[] - } + containers: ContainerConfig[]; + }; } export function isString(value: unknown): value is string { - return value !== undefined && value !== null && typeof value === 'string' || value instanceof String; - + return (value !== undefined && value !== null && typeof value === 'string') || value instanceof String; } export function assertString(value: unknown): string { - if(isString(value)) - return value; + if (isString(value)) return value; throw new Error('value not a string'); } export function parseYaml(raw: string, defaultArch: string): AIConfig { const aiStudioConfig = jsYaml.load(raw); - const application = aiStudioConfig?.["application"]; - if(!application) - throw new Error('AIConfig has bad formatting.'); + const application = aiStudioConfig?.['application']; + if (!application) throw new Error('AIConfig has bad formatting.'); - const containers: unknown[] = application["containers"] ?? []; + const containers: unknown[] = application['containers'] ?? []; return { application: { - containers: containers.map((container) => ({ - arch: isString(container["arch"])?container["arch"]:defaultArch, - modelService: container["model-service"] === true, - containerfile: isString(container["containerfile"])?container["containerfile"]:undefined, - contextdir: assertString(container["contextdir"]), - name: assertString(container["name"]), - })) - } - } + containers: containers.map(container => ({ + arch: isString(container['arch']) ? container['arch'] : defaultArch, + modelService: container['model-service'] === true, + containerfile: isString(container['containerfile']) ? container['containerfile'] : undefined, + contextdir: assertString(container['contextdir']), + name: assertString(container['name']), + })), + }, + }; } diff --git a/packages/backend/src/playground.ts b/packages/backend/src/playground.ts index 612e4f31f..0669b049d 100644 --- a/packages/backend/src/playground.ts +++ b/packages/backend/src/playground.ts @@ -1,6 +1,6 @@ import { provider, containerEngine, type ProviderContainerConnection, type ImageInfo } from '@podman-desktop/api'; -import { LocalModelInfo } from '@shared/models/ILocalModelInfo'; -import { ModelResponse } from '@shared/models/IModelResponse'; +import type { LocalModelInfo } from '@shared/src/models/ILocalModelInfo'; +import type { ModelResponse } from '@shared/src/models/IModelResponse'; import path from 'node:path'; import * as http from 'node:http'; @@ -17,7 +17,7 @@ function findFirstProvider(): ProviderContainerConnection | undefined { export class PlayGroundManager { async selectImage(connection: ProviderContainerConnection, image: string): Promise { - const images = (await containerEngine.listImages()).filter(im => im.RepoTags && im.RepoTags.some(tag => tag === image)); + const images = (await containerEngine.listImages()).filter(im => im.RepoTags?.some(tag => tag === image)); return images.length > 0 ? images[0] : undefined; } @@ -51,10 +51,10 @@ export class PlayGroundManager { PortBindings: { '8080/tcp': [ { - HostPort: '9000' - } - ] - } + HostPort: '9000', + }, + ], + }, }, Cmd: ['--models-path', '/models', '--context-size', '700', '--threads', '4'], }); @@ -71,32 +71,32 @@ export class PlayGroundManager { async askPlayground(modelInfo: LocalModelInfo, prompt: string): Promise { return new Promise(resolve => { - let post_data = JSON.stringify({ - "model": modelInfo.file, - "prompt": prompt, - "temperature": 0.7 + const post_data = JSON.stringify({ + model: modelInfo.file, + prompt: prompt, + temperature: 0.7, }); - let post_options: http.RequestOptions = { + const post_options: http.RequestOptions = { host: 'localhost', port: '9000', path: '/v1/completions', method: 'POST', headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }; - let post_req = http.request(post_options, function (res) { + const post_req = http.request(post_options, function (res) { res.setEncoding('utf8'); const chunks = []; - res.on('data', (data) => chunks.push(data)); + res.on('data', data => chunks.push(data)); res.on('end', () => { - let resBody = chunks.join(); + const resBody = chunks.join(); + const result = JSON.parse(resBody); + console.log('result', result); switch (res.headers['content-type']) { case 'application/json': - const result = JSON.parse(resBody); - console.log('result', result); resolve(result as ModelResponse); break; } diff --git a/packages/backend/src/registries/RecipeStatusRegistry.ts b/packages/backend/src/registries/RecipeStatusRegistry.ts index 8f636a6f3..63820c2f9 100644 --- a/packages/backend/src/registries/RecipeStatusRegistry.ts +++ b/packages/backend/src/registries/RecipeStatusRegistry.ts @@ -1,15 +1,15 @@ -import { RecipeStatus } from '@shared/models/IRecipeStatus'; -import { TaskRegistry } from './TaskRegistry'; +import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; +import type { TaskRegistry } from './TaskRegistry'; export class RecipeStatusRegistry { private statuses: Map = new Map(); - constructor(private taskRegistry: TaskRegistry) { } + constructor(private taskRegistry: TaskRegistry) {} setStatus(recipeId: string, status: RecipeStatus) { // Update the TaskRegistry - if(status.tasks && status.tasks.length > 0) { - status.tasks.map((task) => this.taskRegistry.set(task)); + if (status.tasks && status.tasks.length > 0) { + status.tasks.map(task => this.taskRegistry.set(task)); } this.statuses.set(recipeId, status); } diff --git a/packages/backend/src/registries/TaskRegistry.ts b/packages/backend/src/registries/TaskRegistry.ts index 699fa3a4f..1508a831c 100644 --- a/packages/backend/src/registries/TaskRegistry.ts +++ b/packages/backend/src/registries/TaskRegistry.ts @@ -1,11 +1,10 @@ -import { Task } from '@shared/models/ITask'; +import type { Task } from '@shared/src/models/ITask'; export class TaskRegistry { private tasks: Map = new Map(); getTasksByLabel(label: string): Task[] { - return Array.from(this.tasks.values()) - .filter((task) => label in (task.labels || {})) + return Array.from(this.tasks.values()).filter(task => label in (task.labels || {})); } set(task: Task) { diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index 311fe3caf..5bf8606fc 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -20,59 +20,61 @@ import { expect, test, vi } from 'vitest'; import content from './ai-test.json'; -import { ApplicationManager } from './managers/applicationManager'; -import { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; +import type { ApplicationManager } from './managers/applicationManager'; +import type { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; import { StudioApiImpl } from './studio-api-impl'; -import { PlayGroundManager } from './playground'; -import { TaskRegistry } from './registries/TaskRegistry'; +import type { PlayGroundManager } from './playground'; +import type { TaskRegistry } from './registries/TaskRegistry'; vi.mock('./ai.json', () => { - return { - default: content - }; + return { + default: content, + }; }); const studioApiImpl = new StudioApiImpl( - {} as unknown as ApplicationManager, - {} as unknown as RecipeStatusRegistry, - {} as unknown as TaskRegistry, - {} as unknown as PlayGroundManager, -) + {} as unknown as ApplicationManager, + {} as unknown as RecipeStatusRegistry, + {} as unknown as TaskRegistry, + {} as unknown as PlayGroundManager, +); -test('expect correct model is returned with valid id', async () => { - const model = await 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'); - expect(model.url).toEqual('https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf') +test('expect correct model is returned with valid id', async () => { + const model = await 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'); + expect(model.url).toEqual( + 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf', + ); }); 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', - ); -}) + 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 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); + 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); + const models = await studioApiImpl.getModelsByIds(['1', '2']); + expect(models).toBeDefined(); + expect(models.length).toBe(0); }); diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index 9d495b9f8..58cfad47a 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -1,16 +1,17 @@ -import type { StudioAPI } from '@shared/StudioAPI'; -import { Category } from '@shared/models/ICategory'; -import { Recipe } from '@shared/models/IRecipe'; +import type { StudioAPI } from '@shared/src/StudioAPI'; +import type { Category } from '@shared/src/models/ICategory'; +import type { Recipe } from '@shared/src/models/IRecipe'; import content from './ai.json'; -import { AI_STUDIO_FOLDER, ApplicationManager } from './managers/applicationManager'; -import { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; -import { RecipeStatus } from '@shared/models/IRecipeStatus'; -import { ModelInfo } from '@shared/models/IModelInfo'; -import { TaskRegistry } from './registries/TaskRegistry'; -import { Task } from '@shared/models/ITask'; +import type { ApplicationManager } from './managers/applicationManager'; +import { AI_STUDIO_FOLDER } from './managers/applicationManager'; +import type { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; +import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import type { TaskRegistry } from './registries/TaskRegistry'; +import type { Task } from '@shared/src/models/ITask'; import * as path from 'node:path'; -import { ModelResponse } from '@shared/models/IModelResponse'; -import { PlayGroundManager } from './playground'; +import type { ModelResponse } from '@shared/src/models/IModelResponse'; +import type { PlayGroundManager } from './playground'; import * as podmanDesktopApi from '@podman-desktop/api'; export const RECENT_CATEGORY_ID = 'recent-category'; @@ -24,13 +25,11 @@ export class StudioApiImpl implements StudioAPI { ) {} async openURL(url: string): Promise { - return await podmanDesktopApi.env.openExternal( - podmanDesktopApi.Uri.parse(url) - ); + return await podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.parse(url)); } async getPullingStatus(recipeId: string): Promise { - return this.recipeStatusRegistry.getStatus(recipeId); + return this.recipeStatusRegistry.getStatus(recipeId); } async ping(): Promise { @@ -69,7 +68,8 @@ export class StudioApiImpl implements StudioAPI { return content.models.filter(m => ids.includes(m.id)) ?? []; } - async searchRecipes(query: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async searchRecipes(_query: string): Promise { return []; // todo: not implemented } @@ -83,9 +83,7 @@ export class StudioApiImpl implements StudioAPI { const model = await this.getModelById(modelId); // Do not wait for the pull application, run it separately - new Promise(() => { - this.applicationManager.pullApplication(recipe, model); - }); + this.applicationManager.pullApplication(recipe, model).catch((error: unknown) => console.warn(error)); return Promise.resolve(undefined); } @@ -106,7 +104,13 @@ export class StudioApiImpl implements StudioAPI { throw new Error('model not found'); } - const modelPath = path.resolve(this.applicationManager.homeDirectory, AI_STUDIO_FOLDER, 'models', modelId, localModelInfo[0].file); + const modelPath = path.resolve( + this.applicationManager.homeDirectory, + AI_STUDIO_FOLDER, + 'models', + modelId, + localModelInfo[0].file, + ); await this.playgroundManager.startPlayground(modelId, modelPath); } diff --git a/packages/backend/src/studio.spec.ts b/packages/backend/src/studio.spec.ts index b2bbd3f60..bbf0744b6 100644 --- a/packages/backend/src/studio.spec.ts +++ b/packages/backend/src/studio.spec.ts @@ -39,8 +39,8 @@ vi.mock('@podman-desktop/api', async () => { createWebviewPanel: () => ({ webview: { html: '', - onDidReceiveMessage: vi.fn() - } + onDidReceiveMessage: vi.fn(), + }, }), }, }; @@ -61,7 +61,7 @@ afterEach(() => { test('check activate ', async () => { vi.spyOn(fs.promises, 'readFile').mockImplementation(() => { - return ''; + return Promise.resolve(''); }); await studio.activate(); diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index eb13c103b..1cc5ed91d 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -18,16 +18,13 @@ import type { ExtensionContext, WebviewOptions, WebviewPanel } from '@podman-desktop/api'; import { Uri, window } from '@podman-desktop/api'; -import { RpcExtension } from '@shared/MessageProxy'; +import { RpcExtension } from '@shared/src/MessageProxy'; import { StudioApiImpl } from './studio-api-impl'; import { ApplicationManager } from './managers/applicationManager'; import { GitManager } from './managers/gitManager'; import { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; import * as fs from 'node:fs'; -import * as https from 'node:https'; -import * as path from 'node:path'; -import type { LocalModelInfo } from '@shared/models/ILocalModelInfo'; import { TaskRegistry } from './registries/TaskRegistry'; import { PlayGroundManager } from './playground'; @@ -95,17 +92,8 @@ export class Studio { const gitManager = new GitManager(); const taskRegistry = new TaskRegistry(); const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry); - const applicationManager = new ApplicationManager( - gitManager, - recipeStatusRegistry, - this.#extensionContext, - ) - this.studioApi = new StudioApiImpl( - applicationManager, - recipeStatusRegistry, - taskRegistry, - this.playgroundManager, - ); + const applicationManager = new ApplicationManager(gitManager, recipeStatusRegistry, this.#extensionContext); + this.studioApi = new StudioApiImpl(applicationManager, recipeStatusRegistry, taskRegistry, this.playgroundManager); // Register the instance this.rpcExtension.registerInstance(StudioApiImpl, this.studioApi); } diff --git a/packages/backend/src/utils/pathUtils.ts b/packages/backend/src/utils/pathUtils.ts index 3907f6d35..0d60707d3 100644 --- a/packages/backend/src/utils/pathUtils.ts +++ b/packages/backend/src/utils/pathUtils.ts @@ -1,11 +1,9 @@ import path from 'path'; export function getParentDirectory(filePath: string): string { -// Normalize the path to handle different platform-specific separators + // Normalize the path to handle different platform-specific separators const normalizedPath = path.normalize(filePath); // Get the directory name using path.dirname - const parentDirectory = path.dirname(normalizedPath); - - return parentDirectory; + return path.dirname(normalizedPath); } diff --git a/packages/backend/src/utils/recipeStatusUtils.ts b/packages/backend/src/utils/recipeStatusUtils.ts index 93c3ddc46..e04c0cbca 100644 --- a/packages/backend/src/utils/recipeStatusUtils.ts +++ b/packages/backend/src/utils/recipeStatusUtils.ts @@ -1,13 +1,15 @@ -import type { RecipeStatus, RecipeStatusState } from '@shared/models/IRecipeStatus'; -import type { Task, TaskState } from '@shared/models/ITask'; -import { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; - +import type { RecipeStatus, RecipeStatusState } from '@shared/src/models/IRecipeStatus'; +import type { Task, TaskState } from '@shared/src/models/ITask'; +import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; export class RecipeStatusUtils { private tasks: Map = new Map(); private state: RecipeStatusState = 'loading'; - constructor(private recipeId: string, private recipeStatusRegistry: RecipeStatusRegistry) {} + constructor( + private recipeId: string, + private recipeStatusRegistry: RecipeStatusRegistry, + ) {} update() { this.recipeStatusRegistry.setStatus(this.recipeId, this.toRecipeStatus()); @@ -21,30 +23,27 @@ export class RecipeStatusUtils { setTask(task: Task) { this.tasks.set(task.id, task); - if(task.state === 'error') - this.setStatus('error'); + if (task.state === 'error') this.setStatus('error'); this.update(); } setTaskState(taskId: string, state: TaskState) { - if(!this.tasks.has(taskId)) - throw new Error('task not found.'); + if (!this.tasks.has(taskId)) throw new Error('task not found.'); const task = this.tasks.get(taskId); this.setTask({ ...task, - state: state - }) + state: state, + }); } setTaskProgress(taskId: string, value: number) { - if(!this.tasks.has(taskId)) - throw new Error('task not found.'); + if (!this.tasks.has(taskId)) throw new Error('task not found.'); const task = this.tasks.get(taskId); this.setTask({ ...task, progress: value, - }) + }); } toRecipeStatus(): RecipeStatus { @@ -52,6 +51,6 @@ export class RecipeStatusUtils { recipeId: this.recipeId, state: this.state, tasks: Array.from(this.tasks.values()), - } + }; } } diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index cf719eccd..37fd6fca6 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -6,7 +6,8 @@ "resolveJsonModule": true, "lib": [ "ES2017", - "webworker" + "webworker", + "dom", ], "sourceMap": true, "outDir": "dist", @@ -22,21 +23,9 @@ "include": [ "src", "types/*.d.ts", - "../../types/**/*.d.ts", + "../../types/*.d.ts", "../shared/*.ts", "../shared/**/*.ts" ], - "ts-node": { - "compilerOptions": { - "module": "CommonJS", - "lib": [ - "ES2020", - "DOM" - ], - "types": [ - "node" - ] - } - } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 894bb51e5..251c320e1 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.1.0-next", "type": "module", "scripts": { - "preview": "vite preview", + "preview": "vite preview", "build": "vite build", "test": "vitest run --coverage", "test:watch": "vitest watch --coverage", @@ -16,23 +16,25 @@ "tinro": "^0.6.12" }, "devDependencies": { - "svelte-fa": "^3.0.4", - "svelte-markdown": "^0.4.1", - "@tailwindcss/typography": "^0.5.10", "@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", - "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", + "@fortawesome/free-solid-svg-icons": "^6.5.1", "@sveltejs/vite-plugin-svelte": "3.0.1", + "@tailwindcss/typography": "^0.5.10", "@testing-library/dom": "^9.3.3", + "@testing-library/jest-dom": "^6.2.0", "@testing-library/svelte": "^4.0.5", "@testing-library/user-event": "^14.5.1", "@tsconfig/svelte": "^5.0.2", "@typescript-eslint/eslint-plugin": "6.15.0", + "jsdom": "^23.2.0", + "postcss": "^8.4.33", + "postcss-load-config": "^5.0.2", "svelte": "4.2.8", + "svelte-fa": "^3.0.4", + "svelte-markdown": "^0.4.1", "svelte-preprocess": "^5.1.3", - "tailwindcss": "^3.4.0", - "postcss": "^8.4.33", - "postcss-load-config": "^5.0.2" + "tailwindcss": "^3.4.0" } } diff --git a/packages/frontend/src/pages/Model.svelte b/packages/frontend/src/pages/Model.svelte index 081f33776..453a28890 100644 --- a/packages/frontend/src/pages/Model.svelte +++ b/packages/frontend/src/pages/Model.svelte @@ -3,7 +3,7 @@ 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/models/IModelInfo'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; import { studioClient } from '../utils/client'; import { onMount } from 'svelte'; import ModelPlayground from './ModelPlayground.svelte'; diff --git a/packages/frontend/src/pages/Models.svelte b/packages/frontend/src/pages/Models.svelte index e7711a8c8..613747562 100644 --- a/packages/frontend/src/pages/Models.svelte +++ b/packages/frontend/src/pages/Models.svelte @@ -1,5 +1,5 @@ diff --git a/packages/frontend/src/pages/RecipeModels.svelte b/packages/frontend/src/pages/RecipeModels.svelte index df820a32c..a4def8571 100644 --- a/packages/frontend/src/pages/RecipeModels.svelte +++ b/packages/frontend/src/pages/RecipeModels.svelte @@ -1,5 +1,5 @@