Skip to content

Commit 28e8a62

Browse files
committed
feat: open-webui playground prototype
Signed-off-by: Jeff MAURY <[email protected]>
1 parent 7435841 commit 28e8a62

File tree

7 files changed

+151
-112
lines changed

7 files changed

+151
-112
lines changed

packages/backend/src/assets/webui.db

140 KB
Binary file not shown.

packages/backend/src/managers/playgroundV2Manager.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import { getRandomString } from '../utils/randomUtils';
3636
import type { TaskRegistry } from '../registries/TaskRegistry';
3737
import type { CancellationTokenRegistry } from '../registries/CancellationTokenRegistry';
3838
import { getHash } from '../utils/sha';
39+
import type { ConfigurationRegistry } from '../registries/ConfigurationRegistry';
40+
import type { PodmanConnection } from './podmanConnection';
3941

4042
export class PlaygroundV2Manager implements Disposable {
4143
#conversationRegistry: ConversationRegistry;
@@ -46,17 +48,24 @@ export class PlaygroundV2Manager implements Disposable {
4648
private taskRegistry: TaskRegistry,
4749
private telemetry: TelemetryLogger,
4850
private cancellationTokenRegistry: CancellationTokenRegistry,
51+
configurationRegistry: ConfigurationRegistry,
52+
podmanConnection: PodmanConnection,
4953
) {
50-
this.#conversationRegistry = new ConversationRegistry(webview);
54+
this.#conversationRegistry = new ConversationRegistry(
55+
webview,
56+
configurationRegistry,
57+
taskRegistry,
58+
podmanConnection,
59+
);
5160
}
5261

53-
deleteConversation(conversationId: string): void {
62+
async deleteConversation(conversationId: string): Promise<void> {
5463
const conversation = this.#conversationRegistry.get(conversationId);
5564
this.telemetry.logUsage('playground.delete', {
5665
totalMessages: conversation.messages.length,
5766
modelId: getHash(conversation.modelId),
5867
});
59-
this.#conversationRegistry.deleteConversation(conversationId);
68+
await this.#conversationRegistry.deleteConversation(conversationId);
6069
}
6170

6271
async requestCreatePlayground(name: string, model: ModelInfo): Promise<string> {
@@ -117,11 +126,11 @@ export class PlaygroundV2Manager implements Disposable {
117126
}
118127

119128
// Create conversation
120-
const conversationId = this.#conversationRegistry.createConversation(name, model.id);
129+
const conversationId = await this.#conversationRegistry.createConversation(name, model.id);
121130

122131
// create/start inference server if necessary
123132
const servers = this.inferenceManager.getServers();
124-
const server = servers.find(s => s.models.map(mi => mi.id).includes(model.id));
133+
let server = servers.find(s => s.models.map(mi => mi.id).includes(model.id));
125134
if (!server) {
126135
await this.inferenceManager.createInferenceServer(
127136
await withDefaultConfiguration({
@@ -131,10 +140,15 @@ export class PlaygroundV2Manager implements Disposable {
131140
},
132141
}),
133142
);
143+
server = this.inferenceManager.findServerByModel(model);
134144
} else if (server.status === 'stopped') {
135145
await this.inferenceManager.startInferenceServer(server.container.containerId);
136146
}
137147

148+
if (server && server.status === 'running') {
149+
await this.#conversationRegistry.startConversationContainer(server, trackingId, conversationId);
150+
}
151+
138152
return conversationId;
139153
}
140154

packages/backend/src/registries/ConfigurationRegistry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export class ConfigurationRegistry extends Publisher<ExtensionConfiguration> imp
6262
return path.join(this.appUserDirectory, 'models');
6363
}
6464

65+
public getConversationsPath(): string {
66+
return path.join(this.appUserDirectory, 'conversations');
67+
}
68+
6569
dispose(): void {
6670
this.#configurationDisposable?.dispose();
6771
}

packages/backend/src/registries/ConversationRegistry.ts

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,36 @@ import type {
2525
Message,
2626
PendingChat,
2727
} from '@shared/src/models/IPlaygroundMessage';
28-
import type { Disposable, Webview } from '@podman-desktop/api';
28+
import {
29+
type Disposable,
30+
type Webview,
31+
type ContainerCreateOptions,
32+
containerEngine,
33+
type ContainerProviderConnection,
34+
type ImageInfo,
35+
type PullEvent,
36+
} from '@podman-desktop/api';
2937
import { Messages } from '@shared/Messages';
38+
import type { ConfigurationRegistry } from './ConfigurationRegistry';
39+
import path from 'node:path';
40+
import fs from 'node:fs';
41+
import type { InferenceServer } from '@shared/src/models/IInference';
42+
import { getFreeRandomPort } from '../utils/ports';
43+
import { DISABLE_SELINUX_LABEL_SECURITY_OPTION } from '../utils/utils';
44+
import { getImageInfo } from '../utils/inferenceUtils';
45+
import type { TaskRegistry } from './TaskRegistry';
46+
import type { PodmanConnection } from '../managers/podmanConnection';
3047

3148
export class ConversationRegistry extends Publisher<Conversation[]> implements Disposable {
3249
#conversations: Map<string, Conversation>;
3350
#counter: number;
3451

35-
constructor(webview: Webview) {
52+
constructor(
53+
webview: Webview,
54+
private configurationRegistry: ConfigurationRegistry,
55+
private taskRegistry: TaskRegistry,
56+
private podmanConnection: PodmanConnection,
57+
) {
3658
super(webview, Messages.MSG_CONVERSATIONS_UPDATE, () => this.getAll());
3759
this.#conversations = new Map<string, Conversation>();
3860
this.#counter = 0;
@@ -76,13 +98,32 @@ export class ConversationRegistry extends Publisher<Conversation[]> implements D
7698
this.notify();
7799
}
78100

79-
deleteConversation(id: string): void {
101+
async deleteConversation(id: string): Promise<void> {
102+
const conversation = this.get(id);
103+
if (conversation.container) {
104+
await containerEngine.stopContainer(conversation.container?.engineId, conversation.container?.containerId);
105+
}
106+
await fs.promises.rm(path.join(this.configurationRegistry.getConversationsPath(), id), {
107+
recursive: true,
108+
force: true,
109+
});
80110
this.#conversations.delete(id);
81111
this.notify();
82112
}
83113

84-
createConversation(name: string, modelId: string): string {
114+
async createConversation(name: string, modelId: string): Promise<string> {
85115
const conversationId = this.getUniqueId();
116+
const conversationFolder = path.join(this.configurationRegistry.getConversationsPath(), conversationId);
117+
await fs.promises.mkdir(conversationFolder, {
118+
recursive: true,
119+
});
120+
//WARNING: this will not work in production mode but didn't find how to embed binary assets
121+
//this code get an initialized database so that default user is not admin thus did not get the initial
122+
//welcome modal dialog
123+
await fs.promises.copyFile(
124+
path.join(__dirname, '..', 'src', 'assets', 'webui.db'),
125+
path.join(conversationFolder, 'webui.db'),
126+
);
86127
this.#conversations.set(conversationId, {
87128
name: name,
88129
modelId: modelId,
@@ -93,6 +134,77 @@ export class ConversationRegistry extends Publisher<Conversation[]> implements D
93134
return conversationId;
94135
}
95136

137+
async startConversationContainer(server: InferenceServer, trackingId: string, conversationId: string): Promise<void> {
138+
const conversation = this.get(conversationId);
139+
const port = await getFreeRandomPort('127.0.0.1');
140+
const connection = await this.podmanConnection.getConnectionByEngineId(server.container.engineId);
141+
await this.pullImage(connection, 'ghcr.io/open-webui/open-webui:main', {
142+
trackingId: trackingId,
143+
});
144+
const inferenceServerContainer = await containerEngine.inspectContainer(
145+
server.container.engineId,
146+
server.container.containerId,
147+
);
148+
const options: ContainerCreateOptions = {
149+
Env: [
150+
'DEFAULT_LOCALE=en-US',
151+
'WEBUI_AUTH=false',
152+
'ENABLE_OLLAMA_API=false',
153+
`OPENAI_API_BASE_URL=http://${inferenceServerContainer.NetworkSettings.IPAddress}:8000/v1`,
154+
'OPENAI_API_KEY=sk_dummy',
155+
`WEBUI_URL=http://localhost:${port}`,
156+
`DEFAULT_MODELS=/models/${server.models[0].file?.file}`,
157+
],
158+
Image: 'ghcr.io/open-webui/open-webui:main',
159+
HostConfig: {
160+
AutoRemove: true,
161+
Mounts: [
162+
{
163+
Source: path.join(this.configurationRegistry.getConversationsPath(), conversationId),
164+
Target: '/app/backend/data',
165+
Type: 'bind',
166+
},
167+
],
168+
PortBindings: {
169+
'8080/tcp': [
170+
{
171+
HostPort: `${port}`,
172+
},
173+
],
174+
},
175+
SecurityOpt: [DISABLE_SELINUX_LABEL_SECURITY_OPTION],
176+
},
177+
};
178+
const c = await containerEngine.createContainer(server.container.engineId, options);
179+
conversation.container = { engineId: c.engineId, containerId: c.id, port };
180+
}
181+
182+
protected pullImage(
183+
connection: ContainerProviderConnection,
184+
image: string,
185+
labels: { [id: string]: string },
186+
): Promise<ImageInfo> {
187+
// Creating a task to follow pulling progress
188+
const pullingTask = this.taskRegistry.createTask(`Pulling ${image}.`, 'loading', labels);
189+
190+
// get the default image info for this provider
191+
return getImageInfo(connection, image, (_event: PullEvent) => {})
192+
.catch((err: unknown) => {
193+
pullingTask.state = 'error';
194+
pullingTask.progress = undefined;
195+
pullingTask.error = `Something went wrong while pulling ${image}: ${String(err)}`;
196+
throw err;
197+
})
198+
.then(imageInfo => {
199+
pullingTask.state = 'success';
200+
pullingTask.progress = undefined;
201+
return imageInfo;
202+
})
203+
.finally(() => {
204+
this.taskRegistry.updateTask(pullingTask);
205+
});
206+
}
207+
96208
/**
97209
* This method will be responsible for finalizing the message by concatenating all the choices
98210
* @param conversationId

packages/backend/src/studio.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ export class Studio {
306306
this.#taskRegistry,
307307
this.#telemetry,
308308
this.#cancellationTokenRegistry,
309+
this.#configurationRegistry,
310+
this.#podmanConnection,
309311
);
310312
this.#extensionContext.subscriptions.push(this.#playgroundManager);
311313

packages/frontend/src/pages/Playground.svelte

Lines changed: 5 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -188,110 +188,12 @@ function handleOnClick(): void {
188188
<ConversationActions detailed conversation={conversation} />
189189
</svelte:fragment>
190190
<svelte:fragment slot="content">
191-
<div class="flex flex-col w-full h-full bg-[var(--pd-content-bg)]">
191+
<div class="w-full h-full bg-[var(--pd-content-bg)]">
192192
<div class="h-full overflow-auto" bind:this={scrollable}>
193-
<ContentDetailsLayout detailsTitle="Settings" detailsLabel="settings">
194-
<svelte:fragment slot="content">
195-
<div class="flex flex-col w-full h-full">
196-
<div aria-label="conversation" class="w-full h-full">
197-
{#if conversation}
198-
<!-- Show a banner for the system prompt -->
199-
{#key conversation.messages.length}
200-
<SystemPromptBanner conversation={conversation} />
201-
{/key}
202-
<!-- show all message except the sytem prompt -->
203-
<ul>
204-
{#each messages as message}
205-
<li>
206-
<ChatMessage message={message} />
207-
</li>
208-
{/each}
209-
</ul>
210-
{/if}
211-
</div>
212-
</div>
213-
</svelte:fragment>
214-
<svelte:fragment slot="details">
215-
<div class="text-[var(--pd-content-card-text)]">Next prompt will use these settings</div>
216-
<div
217-
class="bg-[var(--pd-content-card-inset-bg)] text-[var(--pd-content-card-text)] w-full rounded-md p-4">
218-
<div class="mb-4 flex flex-col">Model Parameters</div>
219-
<div class="flex flex-col space-y-4" aria-label="parameters">
220-
<div class="flex flex-row">
221-
<div class="w-full">
222-
<RangeInput name="temperature" min="0" max="2" step="0.1" bind:value={temperature} />
223-
</div>
224-
<Tooltip left>
225-
<Fa class="text-[var(--pd-content-card-icon)]" icon={faCircleInfo} />
226-
<svelte:fragment slot="tip">
227-
<div class="inline-block py-2 px-4 rounded-md" aria-label="tooltip">
228-
What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output
229-
more random, while lower values like 0.2 will make it more focused and deterministic.
230-
</div>
231-
</svelte:fragment>
232-
</Tooltip>
233-
</div>
234-
<div class="flex flex-row">
235-
<div class="w-full">
236-
<RangeInput name="max tokens" min="-1" max="32768" step="1" bind:value={max_tokens} />
237-
</div>
238-
<Tooltip left>
239-
<Fa class="text-[var(--pd-content-card-icon)]" icon={faCircleInfo} />
240-
<svelte:fragment slot="tip">
241-
<div class="inline-block py-2 px-4 rounded-md" aria-label="tooltip">
242-
The maximum number of tokens that can be generated in the chat completion.
243-
</div>
244-
</svelte:fragment>
245-
</Tooltip>
246-
</div>
247-
<div class="flex flex-row">
248-
<div class="w-full">
249-
<RangeInput name="top-p" min="0" max="1" step="0.1" bind:value={top_p} />
250-
</div>
251-
<Tooltip left>
252-
<Fa class="text-[var(--pd-content-card-icon)]" icon={faCircleInfo} />
253-
<svelte:fragment slot="tip">
254-
<div class="inline-block py-2 px-4 rounded-md" aria-label="tooltip">
255-
An alternative to sampling with temperature, where the model considers the results of the
256-
tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10%
257-
probability mass are considered.
258-
</div>
259-
</svelte:fragment>
260-
</Tooltip>
261-
</div>
262-
</div>
263-
</div>
264-
</svelte:fragment>
265-
</ContentDetailsLayout>
266-
</div>
267-
{#if errorMsg}
268-
<div class="text-[var(--pd-input-field-error-text)] p-2">{errorMsg}</div>
269-
{/if}
270-
<div class="flex flex-row flex-none w-full px-4 py-2 bg-[var(--pd-content-card-bg)]">
271-
<textarea
272-
aria-label="prompt"
273-
bind:value={prompt}
274-
use:requestFocus
275-
on:keydown={handleKeydown}
276-
rows="2"
277-
class="w-full p-2 outline-none rounded-sm bg-[var(--pd-content-card-inset-bg)] text-[var(--pd-content-card-text)] placeholder-[var(--pd-content-card-text)]"
278-
placeholder="Type your prompt here"
279-
disabled={!sendEnabled}></textarea>
280-
281-
<div class="flex-none text-right m-4">
282-
{#if !sendEnabled && cancellationTokenId !== undefined}
283-
<Button title="Stop" icon={faStop} type="secondary" on:click={handleOnClick} />
284-
{:else}
285-
<Button
286-
inProgress={!sendEnabled}
287-
disabled={!isHealthy(server?.status, server?.health?.Status) || !prompt?.length}
288-
on:click={askPlayground}
289-
icon={faPaperPlane}
290-
type="secondary"
291-
title={getSendPromptTitle(sendEnabled, server?.status, server?.health?.Status)}
292-
aria-label="Send prompt"></Button>
293-
{/if}
294-
</div>
193+
<iframe
194+
class="h-full w-full"
195+
title={conversation.name}
196+
src="http://localhost:{conversation.container?.port}?lang=en"></iframe>
295197
</div>
296198
</div>
297199
</svelte:fragment>

packages/shared/src/models/IPlaygroundMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export interface Conversation {
5757
messages: Message[];
5858
modelId: string;
5959
name: string;
60+
container?: {
61+
engineId: string;
62+
containerId: string;
63+
port: number;
64+
};
6065
}
6166

6267
export interface Choice {

0 commit comments

Comments
 (0)