diff --git a/packages/backend/src/assets/ai.json b/packages/backend/src/assets/ai.json index 3b1976276..30475a35d 100644 --- a/packages/backend/src/assets/ai.json +++ b/packages/backend/src/assets/ai.json @@ -123,7 +123,8 @@ "memory": 4080218931, "properties": { "chatFormat": "openchat" - } + }, + "sha": "6adeaad8c048b35ea54562c55e454cc32c63118a32c7b8152cf706b290611487" }, { "id": "hf.instructlab.merlinite-7b-lab-GGUF", @@ -136,7 +137,8 @@ "memory": 4370129224, "properties": { "chatFormat": "openchat" - } + }, + "sha": "9ca044d727db34750e1aeb04e3b18c3cf4a8c064a9ac96cf00448c506631d16c" }, { "id": "hf.TheBloke.mistral-7b-instruct-v0.2.Q4_K_M", @@ -146,7 +148,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.2-GGUF/resolve/main/mistral-7b-instruct-v0.2.Q4_K_M.gguf", - "memory": 4370129224 + "memory": 4370129224, + "sha": "3e0039fd0273fcbebb49228943b17831aadd55cbcbf56f0af00499be2040ccf9" }, { "id": "hf.NousResearch.Hermes-2-Pro-Mistral-7B.Q4_K_M", @@ -156,7 +159,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/NousResearch/Hermes-2-Pro-Mistral-7B-GGUF/resolve/main/Hermes-2-Pro-Mistral-7B.Q4_K_M.gguf", - "memory": 4370129224 + "memory": 4370129224, + "sha": "e1e4253b94e3c04c7b6544250f29ad864a56eb2126e61eb440991a8284453674" }, { "id": "hf.ibm.merlinite-7b-Q4_K_M", @@ -169,7 +173,8 @@ "memory": 4370129224, "properties": { "chatFormat": "openchat" - } + }, + "sha": "94f3a16321c9604ca22e970f3b89931ae5b4bbfd4c5d996e2bb606c506590666" }, { "id": "hf.TheBloke.mistral-7b-codealpaca-lora.Q4_K_M", @@ -179,7 +184,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/TheBloke/Mistral-7B-codealpaca-lora-GGUF/resolve/main/mistral-7b-codealpaca-lora.Q4_K_M.gguf", - "memory": 4370129224 + "memory": 4370129224, + "sha": "69c07f27f682ca8da59fcd8a981335876882a2577f0f9df51b49cf6b97fd470f" }, { "id": "hf.TheBloke.mistral-7b-code-16k-qlora.Q4_K_M", @@ -189,7 +195,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/TheBloke/Mistral-7B-Code-16K-qlora-GGUF/resolve/main/mistral-7b-code-16k-qlora.Q4_K_M.gguf", - "memory": 4370129224 + "memory": 4370129224, + "sha": "0f3c9aced2de6caad52323fea5a92a22fba0b4efddb564fda7a3071e0614443f" }, { "id": "hf.froggeric.Cerebrum-1.0-7b-Q4_KS", @@ -202,7 +209,8 @@ "memory": 4144643441, "properties": { "chatFormat": "openchat" - } + }, + "sha": "98861462a0a80e08704631df23ffee860bd5634551c48d069d4daa3c8931bc52" }, { "id": "hf.TheBloke.openchat-3.5-0106.Q4_K_M", @@ -212,7 +220,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/TheBloke/openchat-3.5-0106-GGUF/resolve/main/openchat-3.5-0106.Q4_K_M.gguf", - "memory": 4370129224 + "memory": 4370129224, + "sha": "49190d4d039e6dea463e567ebce707eb001648f4ba01e43eb7fa88d9975fc0ce" }, { "id": "hf.TheBloke.mistral-7b-openorca.Q4_K_M", @@ -222,7 +231,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/TheBloke/Mistral-7B-OpenOrca-GGUF/resolve/main/mistral-7b-openorca.Q4_K_M.gguf", - "memory": 4370129224 + "memory": 4370129224, + "sha": "83967e58c10c25fbe9d358b6d9e9a8212ca8a292061110dcb68511d39133407b" }, { "id": "hf.MaziyarPanahi.phi-2.Q4_K_M", @@ -232,7 +242,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/MaziyarPanahi/phi-2-GGUF/resolve/main/phi-2.Q4_K_M.gguf", - "memory": 1739461755 + "memory": 1739461755, + "sha": "013e0e421b70dc169adb0c0010171202371e907e5f648084e4ddc8ad9985127a" }, { "id": "hf.llmware.dragon-mistral-7b-q4_k_m", @@ -245,7 +256,8 @@ "memory": 4370129224, "properties": { "chatFormat": "openchat" - } + }, + "sha": "1d8f463c4917480b770db5d7921f3d144471891c45a0d25ba3ab3dd753ec620f" }, { "id": "hf.MaziyarPanahi.MixTAO-7Bx2-MoE-Instruct-v7.0.Q4_K_M", @@ -255,7 +267,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/MaziyarPanahi/MixTAO-7Bx2-MoE-Instruct-v7.0-GGUF/resolve/main/MixTAO-7Bx2-MoE-Instruct-v7.0.Q4_K_M.gguf", - "memory": 7784628224 + "memory": 7784628224, + "sha": "f5fcf04c77a5b69ae37791b48df90daa553e40b5a39efc9068258bedef373182" }, { "id": "hf.ggerganov.whisper.cpp", @@ -265,7 +278,8 @@ "registry": "Hugging Face", "license": "Apache-2.0", "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", - "memory": 487010000 + "memory": 487010000, + "sha": "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b" }, { "id": "hf.facebook.detr-resnet-101", @@ -278,7 +292,8 @@ "memory": 242980000, "properties": { "name": "facebook/detr-resnet-101" - } + }, + "sha": "0943b5a9085a95a0e3ecc1c99a7db0451ecb9d79f4dcb543b0939c1a12481a5d" } ], "categories": [ diff --git a/packages/backend/src/managers/modelsManager.spec.ts b/packages/backend/src/managers/modelsManager.spec.ts index a24f22046..75220e87e 100644 --- a/packages/backend/src/managers/modelsManager.spec.ts +++ b/packages/backend/src/managers/modelsManager.spec.ts @@ -28,6 +28,7 @@ import type { ModelInfo } from '@shared/src/models/IModelInfo'; import * as utils from '../utils/utils'; import { TaskRegistry } from '../registries/TaskRegistry'; import type { CancellationTokenRegistry } from '../registries/CancellationTokenRegistry'; +import * as sha from '../utils/sha'; const mocks = vi.hoisted(() => { return { @@ -690,6 +691,34 @@ describe('downloadModel', () => { state: 'success', }); }); + test('fail if model on disk has different sha of the expected value', async () => { + const manager = new ModelsManager( + 'appdir', + {} as Webview, + { + getModels(): ModelInfo[] { + return []; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + cancellationTokenRegistryMock, + ); + vi.spyOn(taskRegistry, 'updateTask'); + vi.spyOn(manager, 'isModelOnDisk').mockReturnValue(true); + vi.spyOn(manager, 'getLocalModelPath').mockReturnValue('path'); + vi.spyOn(sha, 'hasValidSha').mockResolvedValue(false); + await expect(() => + manager.requestDownloadModel({ + id: 'id', + url: 'url', + name: 'name', + sha: 'sha', + } as ModelInfo), + ).rejects.toThrowError( + 'Model name is already present on disk at path but its security hash (SHA) does not match the expected value. This may indicate the file has been altered or corrupted. Please delete it and try again.', + ); + }); test('multiple download request same model - second call after first completed', async () => { mocks.getDownloaderCompleter.mockReturnValue(true); diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index dc1bbb946..483a719cd 100644 --- a/packages/backend/src/managers/modelsManager.ts +++ b/packages/backend/src/managers/modelsManager.ts @@ -33,6 +33,7 @@ import { Uploader } from '../utils/uploader'; import { deleteRemoteModel, getLocalModelFile, isModelUploaded } from '../utils/modelsUtils'; import { getFirstRunningMachineName } from '../utils/podman'; import type { CancellationTokenRegistry } from '../registries/CancellationTokenRegistry'; +import { hasValidSha } from '../utils/sha'; export class ModelsManager implements Disposable { #modelsDir: string; @@ -340,7 +341,7 @@ export class ModelsManager implements Disposable { const target = path.resolve(destDir, path.basename(model.url)); // Create a downloader - const downloader = new Downloader(model.url, target, abortSignal); + const downloader = new Downloader(model.url, target, model.sha, abortSignal); this.#downloaders.set(model.id, downloader); @@ -357,12 +358,26 @@ export class ModelsManager implements Disposable { private async downloadModel(model: ModelInfo, task: Task): Promise { // Check if the model is already on disk. if (this.isModelOnDisk(model.id)) { - task.state = 'success'; task.name = `Model ${model.name} already present on disk`; + + const modelPath = this.getLocalModelPath(model.id); + if (model.sha) { + const isValid = await hasValidSha(modelPath, model.sha); + if (!isValid) { + task.state = 'error'; + task.error = `Model ${model.name} is already present on disk at ${modelPath} but its security hash (SHA) does not match the expected value. This may indicate the file has been altered or corrupted. Please delete it and try again.`; + this.taskRegistry.updateTask(task); // update task + throw new Error( + `Model ${model.name} is already present on disk at ${modelPath} but its security hash (SHA) does not match the expected value. This may indicate the file has been altered or corrupted. Please delete it and try again.`, + ); + } + } + + task.state = 'success'; this.taskRegistry.updateTask(task); // update task // return model path - return this.getLocalModelPath(model.id); + return modelPath; } const abortController = new AbortController(); diff --git a/packages/backend/src/utils/downloader.spec.ts b/packages/backend/src/utils/downloader.spec.ts index 2702ed622..146a38f62 100644 --- a/packages/backend/src/utils/downloader.spec.ts +++ b/packages/backend/src/utils/downloader.spec.ts @@ -96,8 +96,10 @@ test('perform download failed', async () => { const listenerMock = vi.fn(); downloader.onEvent(listenerMock); + const rejectSpy = vi.fn(); + // perform download logic (do not wait) - void downloader.perform('followUpId'); + downloader.perform('followUpId').catch((e: unknown) => rejectSpy(e)); // wait for listener to be registered await vi.waitFor(() => { @@ -122,6 +124,8 @@ test('perform download failed', async () => { status: 'error', }); expect(promises.rm).toHaveBeenCalledWith('dummyTarget.tmp'); + + expect(rejectSpy).toHaveBeenCalledWith('dummyError'); }); test('perform download successfully', async () => { diff --git a/packages/backend/src/utils/downloader.ts b/packages/backend/src/utils/downloader.ts index ffceaf85a..aebdef528 100644 --- a/packages/backend/src/utils/downloader.ts +++ b/packages/backend/src/utils/downloader.ts @@ -18,6 +18,7 @@ import { getDurationSecondsSince } from './utils'; import { createWriteStream, promises } from 'node:fs'; +import crypto from 'node:crypto'; import https from 'node:https'; import { EventEmitter, type Event } from '@podman-desktop/api'; import type { CompletionEvent, ProgressEvent, BaseEvent } from '../models/baseEvent'; @@ -32,6 +33,7 @@ export class Downloader { constructor( private url: string, private target: string, + private sha?: string, private abortSignal?: AbortSignal, ) {} @@ -39,7 +41,7 @@ export class Downloader { return this.target; } - async perform(id: string) { + async perform(id: string): Promise { this.requestedIdentifier = id; const startTime = performance.now(); @@ -66,6 +68,7 @@ export class Downloader { message: `Request cancelled: ${String(err)}.`, }); } + throw err; } finally { this.completed = true; } @@ -90,6 +93,10 @@ export class Downloader { let totalFileSize = 0; let progress = 0; let previousProgressValue = -1; + let checkSum: crypto.Hash; + if (this.sha) { + checkSum = crypto.createHash('sha256'); + } https.get(url, { signal: this.abortSignal }, resp => { // Determine the total size @@ -113,6 +120,9 @@ export class Downloader { // On data resp.on('data', chunk => { + if (checkSum) { + checkSum.update(chunk); + } progress += chunk.length; const progressValue = (progress * 100) / totalFileSize; @@ -150,6 +160,17 @@ export class Downloader { return; } + if (checkSum) { + const actualSha = checkSum.digest('hex'); + if (this.sha !== actualSha) { + callback({ + error: + "The file's security hash (SHA) does not match the expected value. The file may have been altered or corrupted during the download process", + }); + return; + } + } + // If everything is fine we simply rename the tmp file to the expected one promises .rename(tmpFile, this.target) diff --git a/packages/backend/src/utils/sha.spec.ts b/packages/backend/src/utils/sha.spec.ts new file mode 100644 index 000000000..a30ef9933 --- /dev/null +++ b/packages/backend/src/utils/sha.spec.ts @@ -0,0 +1,42 @@ +/********************************************************************** + * 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 + ***********************************************************************/ +import { beforeEach, expect, test, vi } from 'vitest'; +import { promises } from 'node:fs'; +import { hasValidSha } from './sha'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('return true if file has same hash of the expected one', () => { + vi.mock('node:fs'); + vi.spyOn(promises, 'readFile').mockImplementation(() => Promise.resolve(Buffer.from('test'))); + + // sha of test => 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + const isValid = hasValidSha('file', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'); + expect(isValid).toBeTruthy(); +}); + +test('return false if file has different hash of the expected one', () => { + vi.mock('node:fs'); + vi.spyOn(promises, 'readFile').mockImplementation(() => Promise.resolve(Buffer.from('test'))); + + // sha of test => 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + const isValid = hasValidSha('file', 'fakeSha'); + expect(isValid).toBeTruthy(); +}); diff --git a/packages/backend/src/utils/sha.ts b/packages/backend/src/utils/sha.ts new file mode 100644 index 000000000..4b574262a --- /dev/null +++ b/packages/backend/src/utils/sha.ts @@ -0,0 +1,28 @@ +/********************************************************************** + * 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 + ***********************************************************************/ +import crypto from 'node:crypto'; +import { promises } from 'node:fs'; + +export async function hasValidSha(filePath: string, expectedSha: string): Promise { + const checkSum = crypto.createHash('sha256'); + const readStream = await promises.readFile(filePath); + + checkSum.update(readStream); + const actualSha = checkSum.digest('hex'); + return actualSha === expectedSha; +} diff --git a/packages/frontend/src/lib/ErrorMessage.svelte b/packages/frontend/src/lib/ErrorMessage.svelte index 4189ef1b7..3c739ca56 100644 --- a/packages/frontend/src/lib/ErrorMessage.svelte +++ b/packages/frontend/src/lib/ErrorMessage.svelte @@ -5,17 +5,22 @@ import Tooltip from './Tooltip.svelte'; export let error: string; export let icon = false; +export let tooltipPosition: 'top' | 'topLeft' | 'bottomLeft' = 'top'; +export let tooltipClass = ''; {#if icon} {#if error !== undefined && error !== ''} - + {#if error} -
{error}
+
{error}
{/if}
diff --git a/packages/frontend/src/lib/Tooltip.svelte b/packages/frontend/src/lib/Tooltip.svelte index 33ce8be17..54089fc80 100644 --- a/packages/frontend/src/lib/Tooltip.svelte +++ b/packages/frontend/src/lib/Tooltip.svelte @@ -58,6 +58,7 @@ export let bottom = false; export let bottomLeft = false; export let bottomRight = false; export let left = false; +export let tipClass = '';
@@ -65,7 +66,7 @@ export let left = false;
({ requestRemoveLocalModel: vi.fn(), @@ -49,7 +49,7 @@ test('Expect folder and delete button in document', async () => { const d = new Date(); d.setDate(d.getDate() - 2); - const object: ModelInfo = { + const object: ModelInfoUI = { id: 'my-model', description: '', hw: '', @@ -81,7 +81,7 @@ test('Expect folder and delete button in document', async () => { }); test('Expect download button in document', async () => { - const object: ModelInfo = { + const object: ModelInfoUI = { id: 'my-model', description: '', hw: '', @@ -108,7 +108,7 @@ test('Expect download button in document', async () => { }); test('Expect downloadModel to be call on click', async () => { - const object: ModelInfo = { + const object: ModelInfoUI = { id: 'my-model', description: '', hw: '', @@ -134,7 +134,7 @@ test('Expect router to be called when rocket icon clicked', async () => { const gotoMock = vi.spyOn(router, 'goto'); const replaceMock = vi.spyOn(router.location.query, 'replace'); - const object: ModelInfo = { + const object: ModelInfoUI = { id: 'my-model', description: '', hw: '', @@ -160,3 +160,23 @@ test('Expect router to be called when rocket icon clicked', async () => { expect(replaceMock).toHaveBeenCalledWith({ 'model-id': 'my-model' }); }); }); + +test('Expect error tooltip to be shown if action failed', async () => { + const object: ModelInfoUI = { + id: 'my-model', + description: '', + hw: '', + license: '', + name: '', + registry: '', + url: '', + file: undefined, + memory: 1000, + actionError: 'error while executing X', + }; + render(ModelColumnActions, { object }); + + const tooltip = screen.getByLabelText('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip.textContent).equals('error while executing X'); +}); diff --git a/packages/frontend/src/lib/table/model/ModelColumnActions.svelte b/packages/frontend/src/lib/table/model/ModelColumnActions.svelte index be46c46c0..ec5a17da2 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnActions.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnActions.svelte @@ -1,11 +1,13 @@ -{#if object.file !== undefined} - - - -{:else} - -{/if} +
+
+ {#if object.actionError} + + {:else} +
 
+ {/if} +
+
+ {#if object.file !== undefined} + + + + {:else} + + {/if} +
+
diff --git a/packages/frontend/src/lib/table/model/ModelColumnAge.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnAge.spec.ts index 5d4085f36..b509f05d3 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnAge.spec.ts +++ b/packages/frontend/src/lib/table/model/ModelColumnAge.spec.ts @@ -19,14 +19,14 @@ import '@testing-library/jest-dom/vitest'; import { test, expect } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import type { ModelInfo } from '@shared/src/models/IModelInfo'; import ModelColumnCreation from './ModelColumnAge.svelte'; +import type { ModelInfoUI } from '/@/models/ModelInfoUI'; test('Expect simple column styling', async () => { const d = new Date(); d.setDate(d.getDate() - 2); - const object: ModelInfo = { + const object: ModelInfoUI = { id: 'my-model', description: '', hw: '', diff --git a/packages/frontend/src/lib/table/model/ModelColumnAge.svelte b/packages/frontend/src/lib/table/model/ModelColumnAge.svelte index 8ea826801..8ef670aea 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnAge.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnAge.svelte @@ -1,7 +1,7 @@
diff --git a/packages/frontend/src/lib/table/model/ModelColumnLabels.svelte b/packages/frontend/src/lib/table/model/ModelColumnLabels.svelte index f58e73b76..bc9350101 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnLabels.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnLabels.svelte @@ -1,9 +1,9 @@
diff --git a/packages/frontend/src/lib/table/model/ModelColumnName.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnName.spec.ts index 85c2007b1..8bcd8b55c 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnName.spec.ts +++ b/packages/frontend/src/lib/table/model/ModelColumnName.spec.ts @@ -19,14 +19,14 @@ import '@testing-library/jest-dom/vitest'; import { test, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import type { ModelInfo } from '@shared/src/models/IModelInfo'; import ModelColumnName from './ModelColumnName.svelte'; import userEvent from '@testing-library/user-event'; import { router } from 'tinro'; +import type { ModelInfoUI } from '/@/models/ModelInfoUI'; test('Expect model info lower bar to be visible', async () => { const routerMock = vi.spyOn(router, 'goto'); - const object: ModelInfo = { + const object: ModelInfoUI = { id: 'my-model', description: '', hw: '', @@ -58,7 +58,7 @@ test('Expect model info lower bar to be visible', async () => { test('Expect model info lower bar to be visible', async () => { const routerMock = vi.spyOn(router, 'goto'); - const object: ModelInfo = { + const object: ModelInfoUI = { id: 'my-model', description: '', hw: '', diff --git a/packages/frontend/src/lib/table/model/ModelColumnName.svelte b/packages/frontend/src/lib/table/model/ModelColumnName.svelte index be83a7cf1..c968a634a 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnName.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnName.svelte @@ -1,7 +1,7 @@
diff --git a/packages/frontend/src/models/ModelInfoUI.ts b/packages/frontend/src/models/ModelInfoUI.ts new file mode 100644 index 000000000..d4d7aab07 --- /dev/null +++ b/packages/frontend/src/models/ModelInfoUI.ts @@ -0,0 +1,23 @@ +/********************************************************************** + * 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 + ***********************************************************************/ + +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +export interface ModelInfoUI extends ModelInfo { + actionError?: string; +} diff --git a/packages/frontend/src/models/RecipeModelInfo.ts b/packages/frontend/src/models/RecipeModelInfo.ts index b2e017953..6f73bb831 100644 --- a/packages/frontend/src/models/RecipeModelInfo.ts +++ b/packages/frontend/src/models/RecipeModelInfo.ts @@ -16,9 +16,9 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import type { ModelInfoUI } from './ModelInfoUI'; -export interface RecipeModelInfo extends ModelInfo { +export interface RecipeModelInfo extends ModelInfoUI { recommended: boolean; inUse: boolean; } diff --git a/packages/frontend/src/pages/Models.svelte b/packages/frontend/src/pages/Models.svelte index 5d1372930..327546884 100644 --- a/packages/frontend/src/pages/Models.svelte +++ b/packages/frontend/src/pages/Models.svelte @@ -16,40 +16,41 @@ import ModelColumnActions from '../lib/table/model/ModelColumnActions.svelte'; import Tab from '/@/lib/Tab.svelte'; import Route from '/@/Route.svelte'; import { tasks } from '/@/stores/tasks'; -import { catalog } from '../stores/catalog'; import ModelColumnIcon from '../lib/table/model/ModelColumnIcon.svelte'; import Button from '../lib/button/Button.svelte'; import { router } from 'tinro'; +import type { ModelInfoUI } from '../models/ModelInfoUI'; -const columns: Column[] = [ - new Column('', { width: '40px', renderer: ModelColumnIcon }), - new Column('Name', { +const columns: Column[] = [ + new Column('', { width: '40px', renderer: ModelColumnIcon }), + new Column('Name', { width: '3fr', renderer: ModelColumnName, comparator: (a, b) => b.name.localeCompare(a.name), }), - new Column('Size', { + new Column('Size', { width: '50px', renderer: ModelColumnSize, comparator: (a, b) => (a.file?.size ?? 0) - (b.file?.size ?? 0), }), - new Column('Age', { + new Column('Age', { width: '70px', renderer: ModelColumnAge, comparator: (a, b) => (a.file?.creation?.getTime() ?? 0) - (b.file?.creation?.getTime() ?? 0), }), - new Column('', { width: '225px', align: 'right', renderer: ModelColumnLabels }), - new Column('Actions', { align: 'right', width: '120px', renderer: ModelColumnActions }), + new Column('', { width: '225px', align: 'right', renderer: ModelColumnLabels }), + new Column('Actions', { align: 'right', width: '120px', renderer: ModelColumnActions, overflow: true }), ]; const row = new Row({}); let loading: boolean = true; let pullingTasks: Task[] = []; +let activeTasks: Task[] = []; let models: ModelInfo[] = []; // filtered mean, we remove the models that are being downloaded -let filteredModels: ModelInfo[] = []; +let filteredModels: ModelInfoUI[] = []; $: localModels = filteredModels.filter(model => model.file && model.url); $: remoteModels = filteredModels.filter(model => !model.file); @@ -57,13 +58,22 @@ $: importedModels = filteredModels.filter(model => !model.url); function filterModels(): void { // Let's collect the models we do not want to show (loading, error). - const modelsId: string[] = pullingTasks.reduce((previousValue, currentValue) => { - if (currentValue.labels !== undefined) { + const modelsId: string[] = activeTasks.reduce((previousValue, currentValue) => { + if (currentValue.labels !== undefined && currentValue.state !== 'error') { previousValue.push(currentValue.labels['model-pulling']); } return previousValue; }, [] as string[]); - filteredModels = models.filter(model => !modelsId.includes(model.id)); + pullingTasks = activeTasks.filter(task => task.state === 'loading'); + filteredModels = models + .filter(model => !modelsId.includes(model.id)) + .map(model => { + return { + ...model, + actionError: activeTasks.find(task => task.state === 'error' && task.labels?.['model-pulling'] === model.id) + ?.error, + }; + }); } onMount(() => { @@ -71,9 +81,9 @@ onMount(() => { const tasksUnsubscribe = tasks.subscribe(value => { // Filter out duplicates const modelIds = new Set(); - pullingTasks = value.reduce((filtered: Task[], task: Task) => { + activeTasks = value.reduce((filtered: Task[], task: Task) => { if ( - task.state === 'loading' && + (task.state === 'loading' || task.state === 'error') && task.labels !== undefined && 'model-pulling' in task.labels && !modelIds.has(task.labels['model-pulling']) diff --git a/packages/shared/src/models/IModelInfo.ts b/packages/shared/src/models/IModelInfo.ts index ebcb3b09c..3e4ce5b75 100644 --- a/packages/shared/src/models/IModelInfo.ts +++ b/packages/shared/src/models/IModelInfo.ts @@ -32,6 +32,7 @@ export interface ModelInfo { properties?: { [key: string]: string; }; + sha?: string; } export type ModelCheckerContext = 'inference' | 'recipe'; diff --git a/yarn.lock b/yarn.lock index 594cc8372..94e9c2b2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4277,16 +4277,7 @@ stop-iteration-iterator@^1.0.0: dependencies: internal-slot "^1.0.4" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4338,14 +4329,7 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4970,16 +4954,7 @@ why-is-node-running@^2.2.2: siginfo "^2.0.0" stackback "0.0.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==