Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: delete model #134

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 176 additions & 16 deletions packages/backend/src/managers/modelsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ import type { Webview } from '@podman-desktop/api';
import type { CatalogManager } from './catalogManager';
import type { ModelInfo } from '@shared/src/models/IModelInfo';

const mocks = vi.hoisted(() => {
return {
showErrorMessageMock: vi.fn(),
};
});

vi.mock('@podman-desktop/api', () => {
return {
fs: {
createFileSystemWatcher: () => ({
onDidCreate: vi.fn(),
onDidDelete: vi.fn(),
onDidChange: vi.fn(),
}),
},
window: {
showErrorMessage: mocks.showErrorMessageMock,
},
};
});

beforeEach(() => {
vi.resetAllMocks();
});
Expand Down Expand Up @@ -34,7 +55,7 @@ function mockFiles(now: Date) {
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
existsSyncSpy.mockImplementation((path: string) => {
if (process.platform === 'win32') {
expect(path).toBe('\\home\\user\\aistudio\\models');
expect(path).toBe('C:\\home\\user\\aistudio\\models');
} else {
expect(path).toBe('/home/user/aistudio/models');
}
Expand Down Expand Up @@ -62,7 +83,13 @@ function mockFiles(now: Date) {
test('getLocalModelsFromDisk should get models in local directory', () => {
const now = new Date();
mockFiles(now);
const manager = new ModelsManager('/home/user/aistudio', {} as Webview, {} as CatalogManager);
let appdir: string;
if (process.platform === 'win32') {
appdir = 'C:\\home\\user\\aistudio';
} else {
appdir = '/home/user/aistudio';
}
const manager = new ModelsManager(appdir, {} as Webview, {} as CatalogManager);
manager.getLocalModelsFromDisk();
expect(manager.getLocalModels()).toEqual([
{
Expand All @@ -86,11 +113,17 @@ test('getLocalModelsFromDisk should return an empty array if the models folder d
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
const existsSyncSpy = vi.spyOn(fs, 'existsSync');
existsSyncSpy.mockReturnValue(false);
const manager = new ModelsManager('/home/user/aistudio', {} as Webview, {} as CatalogManager);
let appdir: string;
if (process.platform === 'win32') {
appdir = 'C:\\home\\user\\aistudio';
} else {
appdir = '/home/user/aistudio';
}
const manager = new ModelsManager(appdir, {} as Webview, {} as CatalogManager);
manager.getLocalModelsFromDisk();
expect(manager.getLocalModels()).toEqual([]);
if (process.platform === 'win32') {
expect(existsSyncSpy).toHaveBeenCalledWith('\\home\\user\\aistudio\\models');
expect(existsSyncSpy).toHaveBeenCalledWith('C:\\home\\user\\aistudio\\models');
} else {
expect(existsSyncSpy).toHaveBeenCalledWith('/home/user/aistudio/models');
}
Expand All @@ -100,20 +133,15 @@ test('loadLocalModels should post a message with the message on disk and on cata
const now = new Date();
mockFiles(now);

vi.mock('@podman-desktop/api', () => {
return {
fs: {
createFileSystemWatcher: () => ({
onDidCreate: vi.fn(),
onDidDelete: vi.fn(),
onDidChange: vi.fn(),
}),
},
};
});
const postMessageMock = vi.fn();
let appdir: string;
if (process.platform === 'win32') {
appdir = 'C:\\home\\user\\aistudio';
} else {
appdir = '/home/user/aistudio';
}
const manager = new ModelsManager(
'/home/user/aistudio',
appdir,
{
postMessage: postMessageMock,
} as unknown as Webview,
Expand Down Expand Up @@ -144,3 +172,135 @@ test('loadLocalModels should post a message with the message on disk and on cata
],
});
});

test('deleteLocalModel deletes the model folder', async () => {
let appdir: string;
if (process.platform === 'win32') {
appdir = 'C:\\home\\user\\aistudio';
} else {
appdir = '/home/user/aistudio';
}
const now = new Date();
mockFiles(now);
const rmSpy = vi.spyOn(fs.promises, 'rm');
rmSpy.mockResolvedValue();
const postMessageMock = vi.fn();
const manager = new ModelsManager(
appdir,
{
postMessage: postMessageMock,
} as unknown as Webview,
{
getModels: () => {
return [
{
id: 'model-id-1',
},
] as ModelInfo[];
},
} as CatalogManager,
);
manager.getLocalModelsFromDisk();
await manager.deleteLocalModel('model-id-1');
// check that the model's folder is removed from disk
if (process.platform === 'win32') {
expect(rmSpy).toBeCalledWith('C:\\home\\user\\aistudio\\models\\model-id-1', { recursive: true });
} else {
expect(rmSpy).toBeCalledWith('/home/user/aistudio/models/model-id-1', { recursive: true });
}
expect(postMessageMock).toHaveBeenCalledTimes(2);
// check that a state is sent with the model being deleted
expect(postMessageMock).toHaveBeenCalledWith({
id: 'new-local-models-state',
body: [
{
file: {
creation: now,
file: 'model-id-1-model',
id: 'model-id-1',
size: 32000,
path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'),
},
id: 'model-id-1',
state: 'deleting',
},
],
});
// check that a new state is sent with the model removed
expect(postMessageMock).toHaveBeenCalledWith({
id: 'new-local-models-state',
body: [],
});
});

test('deleteLocalModel fails to delete the model folder', async () => {
let appdir: string;
if (process.platform === 'win32') {
appdir = 'C:\\home\\user\\aistudio';
} else {
appdir = '/home/user/aistudio';
}
const now = new Date();
mockFiles(now);
const rmSpy = vi.spyOn(fs.promises, 'rm');
rmSpy.mockRejectedValue(new Error('failed'));
const postMessageMock = vi.fn();
const manager = new ModelsManager(
appdir,
{
postMessage: postMessageMock,
} as unknown as Webview,
{
getModels: () => {
return [
{
id: 'model-id-1',
},
] as ModelInfo[];
},
} as CatalogManager,
);
manager.getLocalModelsFromDisk();
await manager.deleteLocalModel('model-id-1');
// check that the model's folder is removed from disk
if (process.platform === 'win32') {
expect(rmSpy).toBeCalledWith('C:\\home\\user\\aistudio\\models\\model-id-1', { recursive: true });
} else {
expect(rmSpy).toBeCalledWith('/home/user/aistudio/models/model-id-1', { recursive: true });
}
expect(postMessageMock).toHaveBeenCalledTimes(2);
// check that a state is sent with the model being deleted
expect(postMessageMock).toHaveBeenCalledWith({
id: 'new-local-models-state',
body: [
{
file: {
creation: now,
file: 'model-id-1-model',
id: 'model-id-1',
size: 32000,
path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'),
},
id: 'model-id-1',
state: 'deleting',
},
],
});
// check that a new state is sent with the model non removed
expect(postMessageMock).toHaveBeenCalledWith({
id: 'new-local-models-state',
body: [
{
file: {
creation: now,
file: 'model-id-1-model',
id: 'model-id-1',
size: 32000,
path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'),
},
id: 'model-id-1',
},
],
});
expect(mocks.showErrorMessageMock).toHaveBeenCalledOnce();
});
47 changes: 41 additions & 6 deletions packages/backend/src/managers/modelsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import * as path from 'node:path';
import { type Webview, fs as apiFs } from '@podman-desktop/api';
import { MSG_NEW_LOCAL_MODELS_STATE } from '@shared/Messages';
import type { CatalogManager } from './catalogManager';
import type { ModelInfo } from '@shared/src/models/IModelInfo';
import * as podmanDesktopApi from '@podman-desktop/api';

export class ModelsManager {
#modelsDir: string;
#localModels: Map<string, LocalModelInfo>;
// models being deleted
#deleted: Set<string>;

constructor(
private appUserDirectory: string,
Expand All @@ -16,16 +20,13 @@ export class ModelsManager {
) {
this.#modelsDir = path.join(this.appUserDirectory, 'models');
this.#localModels = new Map();
this.#deleted = new Set();
}

async loadLocalModels() {
const reloadLocalModels = async () => {
this.getLocalModelsFromDisk();
const models = this.getModelsInfo();
await this.webview.postMessage({
id: MSG_NEW_LOCAL_MODELS_STATE,
body: models,
});
await this.sendModelsInfo();
};
const watcher = apiFs.createFileSystemWatcher(this.#modelsDir);
watcher.onDidCreate(reloadLocalModels);
Expand All @@ -39,7 +40,22 @@ export class ModelsManager {
return this.catalogManager
.getModels()
.filter(m => this.#localModels.has(m.id))
.map(m => ({ ...m, file: this.#localModels.get(m.id) }));
.map(
m =>
({
...m,
file: this.#localModels.get(m.id),
state: this.#deleted.has(m.id) ? 'deleting' : undefined,
}) as ModelInfo,
);
}

async sendModelsInfo() {
const models = this.getModelsInfo();
await this.webview.postMessage({
id: MSG_NEW_LOCAL_MODELS_STATE,
body: models,
});
}

getLocalModelsFromDisk(): void {
Expand Down Expand Up @@ -85,7 +101,26 @@ export class ModelsManager {
return path.resolve(this.#modelsDir, modelId, info.file);
}

getLocalModelFolder(modelId: string): string {
return path.resolve(this.#modelsDir, modelId);
}

getLocalModels(): LocalModelInfo[] {
return Array.from(this.#localModels.values());
}

async deleteLocalModel(modelId: string): Promise<void> {
const modelDir = this.getLocalModelFolder(modelId);
this.#deleted.add(modelId);
await this.sendModelsInfo();
try {
await fs.promises.rm(modelDir, { recursive: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recursive option for deleting always scares me, something we could do, is listing all the files inside the model directory, ask user for confirmation something like

"The following files will be deleted

  • model-a.gguff
    "

Then delete them by name instead of deleting the folder ? (I am maybe just overreacting)

this.#localModels.delete(modelId);
} catch (err: unknown) {
await podmanDesktopApi.window.showErrorMessage(`Error deleting model ${modelId}. ${String(err)}`);
} finally {
this.#deleted.delete(modelId);
await this.sendModelsInfo();
}
}
}
4 changes: 4 additions & 0 deletions packages/backend/src/studio-api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,8 @@ export class StudioApiImpl implements StudioAPI {
async getCatalog(): Promise<Catalog> {
return this.catalogManager.getCatalog();
}

async deleteLocalModel(modelId: string): Promise<void> {
await this.modelsManager.deleteLocalModel(modelId);
}
}
62 changes: 62 additions & 0 deletions packages/frontend/src/lib/Modal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<style>
.modal-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
}

.modal {
position: absolute;
left: 50%;
top: 50%;
width: calc(200vw - 4em);
max-width: 42em;
max-height: calc(100vh - 4em);
overflow: auto;
transform: translate(-50%, -50%);
padding: 1em;
border-radius: 0.2em;
z-index: 50;
}
</style>

<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { tabWithinParent } from './dialog-utils';

const dispatch = createEventDispatcher();
const close = () => dispatch('close');

let modal: HTMLDivElement;
export let name = '';

const handle_keydown = (e: any) => {
if (e.key === 'Escape') {
close();
return;
}

if (e.key === 'Tab') {
tabWithinParent(e, modal);
}
};

const previously_focused = typeof document !== 'undefined' && (document.activeElement as HTMLElement);

if (previously_focused) {
onDestroy(() => {
previously_focused.focus();
});
}
</script>

<svelte:window on:keydown="{handle_keydown}" />

<button class="modal-background" on:click="{close}"></button>

<div class="modal" role="dialog" aria-label="{name}" aria-modal="true" bind:this="{modal}">
<slot />
</div>
Loading
Loading