Skip to content

Commit bc087d7

Browse files
committed
feat: added ability to select runtime for playground
Signed-off-by: Brian <[email protected]>
1 parent fb265b1 commit bc087d7

File tree

5 files changed

+238
-30
lines changed

5 files changed

+238
-30
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import { beforeEach, vi, test, expect } from 'vitest';
21+
import { render, fireEvent, within } from '@testing-library/svelte';
22+
import InferenceRuntimeSelect from '/@/lib/select/InferenceRuntimeSelect.svelte';
23+
import { InferenceType } from '@shared/models/IInference';
24+
25+
const getExpectedOptions = (): InferenceType[] => Object.values(InferenceType).filter(t => t !== InferenceType.NONE);
26+
27+
beforeEach(() => {
28+
// mock scrollIntoView
29+
window.HTMLElement.prototype.scrollIntoView = vi.fn();
30+
});
31+
32+
test('Lists all runtime options', async () => {
33+
const { container } = render(InferenceRuntimeSelect, {
34+
value: undefined,
35+
disabled: false,
36+
});
37+
38+
const input = within(container).getByLabelText('Select Inference Runtime');
39+
await fireEvent.pointerUp(input);
40+
41+
const items = container.querySelectorAll('div[class~="list-item"]');
42+
const expectedOptions = getExpectedOptions();
43+
44+
expect(items.length).toBe(expectedOptions.length);
45+
46+
expectedOptions.forEach((option, i) => {
47+
expect(items[i]).toHaveTextContent(option);
48+
});
49+
});
50+
51+
test('Selecting "all" works correctly', async () => {
52+
const { container } = render(InferenceRuntimeSelect, {
53+
value: undefined,
54+
disabled: false,
55+
});
56+
57+
const input = within(container).getByLabelText('Select Inference Runtime');
58+
await fireEvent.pointerUp(input);
59+
60+
const allOption = Array.from(container.querySelectorAll('div[class~="list-item"]')).find(item =>
61+
item.textContent?.includes(InferenceType.ALL),
62+
);
63+
64+
expect(allOption).toBeDefined();
65+
if (allOption) {
66+
await fireEvent.click(allOption);
67+
}
68+
69+
const selected = within(container).getByText(InferenceType.ALL);
70+
expect(selected).toBeInTheDocument();
71+
});
72+
73+
test('Selected value should be visible', async () => {
74+
const { container } = render(InferenceRuntimeSelect, {
75+
value: undefined,
76+
disabled: false,
77+
});
78+
79+
const input = within(container).getByLabelText('Select Inference Runtime');
80+
await fireEvent.pointerUp(input);
81+
82+
const items = container.querySelectorAll('div[class~="list-item"]');
83+
const expectedOptions = getExpectedOptions();
84+
85+
await fireEvent.click(items[0]);
86+
87+
const valueContainer = container.querySelector('.value-container');
88+
if (!(valueContainer instanceof HTMLElement)) throw new Error('Missing value container');
89+
90+
const selectedLabel = within(valueContainer).getByText(expectedOptions[0]);
91+
expect(selectedLabel).toBeDefined();
92+
});
93+
94+
test('Exclude specific runtime from list', async () => {
95+
const excluded = [InferenceType.WHISPER_CPP, InferenceType.OPENVINO];
96+
97+
const { container } = render(InferenceRuntimeSelect, {
98+
value: undefined,
99+
disabled: false,
100+
exclude: excluded,
101+
});
102+
103+
const input = within(container).getByLabelText('Select Inference Runtime');
104+
await fireEvent.pointerUp(input);
105+
106+
const items = container.querySelectorAll('div[class~="list-item"]');
107+
const itemTexts = Array.from(items).map(item => item.textContent?.trim());
108+
109+
excluded.forEach(excludedType => {
110+
expect(itemTexts).not.toContain(excludedType);
111+
});
112+
113+
const includedTypes = Object.values(InferenceType).filter(t => t !== InferenceType.NONE && !excluded.includes(t));
114+
115+
includedTypes.forEach(included => {
116+
expect(itemTexts).toContain(included);
117+
});
118+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script lang="ts">
2+
import Select from '/@/lib/select/Select.svelte';
3+
import { InferenceType } from '@shared/models/IInference';
4+
5+
interface Props {
6+
disabled?: boolean;
7+
value: InferenceType | undefined;
8+
exclude?: InferenceType[];
9+
}
10+
let { value = $bindable(), disabled, exclude = [] }: Props = $props();
11+
12+
// Filter options based on exclude list
13+
const options = Object.values(InferenceType).filter(type => type !== InferenceType.NONE) as Array<InferenceType>;
14+
const filteredOptions = options.filter(type => type === InferenceType.ALL || !exclude.includes(type));
15+
16+
function handleOnChange(nValue: { value: string } | undefined): void {
17+
if (nValue) {
18+
value = nValue.value as InferenceType;
19+
} else {
20+
value = undefined;
21+
}
22+
}
23+
</script>
24+
25+
<Select
26+
label="Select Inference Runtime"
27+
name="select-inference-runtime"
28+
disabled={disabled}
29+
value={value ? { label: value, value: value } : undefined}
30+
onchange={handleOnChange}
31+
placeholder="Select Inference Runtime to use"
32+
items={filteredOptions.map(type => ({
33+
value: type,
34+
label: type,
35+
}))} />

packages/frontend/src/pages/PlaygroundCreate.spec.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ const dummyWhisperCppModel: ModelInfo = {
5555
backend: InferenceType.WHISPER_CPP,
5656
};
5757

58+
const dummyOpenVinoModel: ModelInfo = {
59+
id: 'openvino-model-id',
60+
name: 'Dummy Openvino model',
61+
file: {
62+
file: 'file',
63+
path: path.resolve(os.tmpdir(), 'path'),
64+
},
65+
properties: {},
66+
description: '',
67+
backend: InferenceType.OPENVINO,
68+
};
69+
5870
vi.mock('../utils/client', async () => {
5971
return {
6072
studioClient: {
@@ -102,14 +114,31 @@ test('model should be selected by default', () => {
102114
expect(model).toBeInTheDocument();
103115
});
104116

105-
test('models with incompatible backend should not be listed', async () => {
106-
const modelsInfoList = writable<ModelInfo[]>([dummyWhisperCppModel]);
117+
test('selecting a runtime filters the displayed models', async () => {
118+
const modelsInfoList = writable<ModelInfo[]>([dummyLlamaCppModel, dummyWhisperCppModel, dummyOpenVinoModel]);
119+
vi.mocked(modelsInfoStore).modelsInfo = modelsInfoList;
120+
const { container } = render(PlaygroundCreate);
121+
122+
// Select our runtime
123+
const dropdown = within(container).getByLabelText('Select Inference Runtime');
124+
await userEvent.click(dropdown);
125+
126+
const openvinoOption = within(container).getByText(InferenceType.OPENVINO);
127+
await userEvent.click(openvinoOption);
128+
129+
expect(within(container).queryByText(dummyOpenVinoModel.name)).toBeInTheDocument();
130+
expect(within(container).queryByText(dummyLlamaCppModel.name)).toBeNull();
131+
expect(within(container).queryByText(dummyWhisperCppModel.name)).toBeNull();
132+
});
133+
134+
test('should show warning when no local models are available', () => {
135+
const modelsInfoList = writable<ModelInfo[]>([]);
107136
vi.mocked(modelsInfoStore).modelsInfo = modelsInfoList;
108137

109138
const { container } = render(PlaygroundCreate);
110139

111-
const model = within(container).queryByText(dummyWhisperCppModel.name);
112-
expect(model).toBeNull();
140+
const warning = within(container).getByText(/You don't have any models downloaded/);
141+
expect(warning).toBeInTheDocument();
113142
});
114143

115144
test('should display error message if createPlayground fails', async () => {

packages/frontend/src/pages/PlaygroundCreate.svelte

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,19 @@ import type { Unsubscriber } from 'svelte/store';
1414
import { Button, ErrorMessage, FormPage, Input } from '@podman-desktop/ui-svelte';
1515
import ModelSelect from '/@/lib/select/ModelSelect.svelte';
1616
import { InferenceType } from '@shared/models/IInference';
17+
import InferenceRuntimeSelect from '/@/lib/select/InferenceRuntimeSelect.svelte';
1718
19+
// Preset the runtime selection
20+
let runtime: InferenceType;
21+
runtime = InferenceType.ALL;
22+
// exlude whisper.cpp from selection
23+
let exclude: InferenceType[] = [InferenceType.WHISPER_CPP];
1824
let localModels: ModelInfo[];
19-
$: localModels = $modelsInfo.filter(model => model.file && model.backend !== InferenceType.WHISPER_CPP);
25+
// Special case for "ALL" returns runtimes with optional exclution
26+
$: localModels =
27+
runtime === InferenceType.ALL
28+
? $modelsInfo.filter(model => model.file && !exclude.includes(model.backend as InferenceType))
29+
: $modelsInfo.filter(model => model.file && model.backend === runtime);
2030
$: availModels = $modelsInfo.filter(model => !model.file);
2131
let model: ModelInfo | undefined = undefined;
2232
let submitted: boolean = false;
@@ -30,10 +40,10 @@ let trackingId: string | undefined = undefined;
3040
// The trackedTasks are the tasks linked to the trackingId
3141
let trackedTasks: Task[] = [];
3242
33-
$: {
34-
if (!model && localModels.length > 0) {
35-
model = localModels[0];
36-
}
43+
$: if (localModels.length > 0) {
44+
model = localModels[0];
45+
} else {
46+
model = undefined;
3747
}
3848
3949
function openModelsPage(): void {
@@ -145,33 +155,47 @@ export function goToUpPage(): void {
145155
placeholder="Leave blank to generate a name"
146156
aria-label="playgroundName" />
147157

148-
<!-- model input -->
149-
<label for="model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]">Model</label>
150-
<ModelSelect models={localModels} disabled={submitted} bind:value={model} />
151-
{#if localModels.length === 0}
158+
<!-- inference runtime -->
159+
<label for="inference-runtime" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]">
160+
Inference Runtime
161+
</label>
162+
<InferenceRuntimeSelect bind:value={runtime} exclude={exclude} />
163+
{#if !runtime}
152164
<div class="text-red-500 p-1 flex flex-row items-center">
153165
<Fa size="1.1x" class="cursor-pointer text-red-500" icon={faExclamationCircle} />
154166
<div role="alert" aria-label="Error Message Content" class="ml-2">
155-
You don't have any models downloaded. You can download them in <a
156-
href="javascript:void(0);"
157-
class="underline"
158-
title="Models page"
159-
on:click={openModelsPage}>models page</a
160-
>.
167+
Please select an inference runtime before selecting a model.
161168
</div>
162169
</div>
163-
{:else if availModels.length > 0}
164-
<div class="text-sm p-1 flex flex-row items-center text-[var(--pd-content-card-text)]">
165-
<Fa size="1.1x" class="cursor-pointer" icon={faInfoCircle} />
166-
<div role="alert" aria-label="Info Message Content" class="ml-2">
167-
Other models are available, but must be downloaded from the <a
168-
href="javascript:void(0);"
169-
class="underline"
170-
title="Models page"
171-
on:click={openModelsPage}>models page</a
172-
>.
170+
{:else}
171+
<!-- model input -->
172+
<label for="model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]">Model</label>
173+
<ModelSelect models={localModels} disabled={submitted} bind:value={model} />
174+
{#if localModels.length === 0}
175+
<div class="text-red-500 p-1 flex flex-row items-center">
176+
<Fa size="1.1x" class="cursor-pointer text-red-500" icon={faExclamationCircle} />
177+
<div role="alert" aria-label="Error Message Content" class="ml-2">
178+
You don't have any models downloaded. You can download them in <a
179+
href="javascript:void(0);"
180+
class="underline"
181+
title="Models page"
182+
on:click={openModelsPage}>models page</a
183+
>.
184+
</div>
173185
</div>
174-
</div>
186+
{:else if availModels.length > 0}
187+
<div class="text-sm p-1 flex flex-row items-center text-[var(--pd-content-card-text)]">
188+
<Fa size="1.1x" class="cursor-pointer" icon={faInfoCircle} />
189+
<div role="alert" aria-label="Info Message Content" class="ml-2">
190+
Other models are available, but must be downloaded from the <a
191+
href="javascript:void(0);"
192+
class="underline"
193+
title="Models page"
194+
on:click={openModelsPage}>models page</a
195+
>.
196+
</div>
197+
</div>
198+
{/if}
175199
{/if}
176200
</div>
177201
{#if errorMsg !== undefined}

packages/shared/src/models/IInference.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ export enum InferenceType {
2121
LLAMA_CPP = 'llama-cpp',
2222
WHISPER_CPP = 'whisper-cpp',
2323
OPENVINO = 'openvino',
24+
ALL = 'all',
2425
NONE = 'none',
2526
}
2627

2728
const InferenceTypeLabel = {
2829
'llama-cpp': 'llamacpp',
2930
'whisper-cpp': 'whispercpp',
3031
openvino: 'openvino',
32+
all: 'all',
3133
none: 'None',
3234
};
3335

0 commit comments

Comments
 (0)