feat(llm-playground): add SvelteKit LLM playground UI

- Chat interface with message history
- Model selector for available LLM models
- Parameter panel (temperature, max tokens, etc.)
- System prompt editor
- Svelte 5 runes-based stores

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-30 17:52:10 +01:00
parent 3edbd0cb26
commit f880ef2b7f
24 changed files with 1245 additions and 0 deletions

7
services/llm-playground/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
.svelte-kit/
.turbo/
.env
dist/
build/
*.log

View file

@ -0,0 +1,26 @@
{
"name": "@mana-llm/playground",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev --port 5190",
"build": "vite build",
"preview": "vite preview",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/vite": "^4.1.7",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.1.7"
},
"dependencies": {
"marked": "^17.0.0"
}
}

View file

@ -0,0 +1,103 @@
@import 'tailwindcss';
:root {
--color-bg: #0f0f0f;
--color-surface: #1a1a1a;
--color-surface-hover: #252525;
--color-border: #333;
--color-text: #e5e5e5;
--color-text-muted: #888;
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-success: #22c55e;
--color-error: #ef4444;
--color-warning: #f59e0b;
}
html {
background-color: var(--color-bg);
color: var(--color-text);
}
body {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}
/* Prose styles for markdown */
.prose {
line-height: 1.6;
}
.prose pre {
background: var(--color-bg);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.prose code {
background: var(--color-bg);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.prose pre code {
background: transparent;
padding: 0;
}
.prose p {
margin: 0.5rem 0;
}
.prose ul,
.prose ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.prose li {
margin: 0.25rem 0;
}
.prose h1,
.prose h2,
.prose h3 {
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.prose blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
margin: 0.5rem 0;
color: var(--color-text-muted);
}

13
services/llm-playground/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
/// <reference types="@sveltejs/kit" />
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,112 @@
import type {
ChatCompletionRequest,
ChatCompletionResponse,
HealthResponse,
ModelsResponse,
StreamChunk,
} from '$lib/types';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025';
export async function getHealth(): Promise<HealthResponse> {
const response = await fetch(`${API_BASE}/health`);
if (!response.ok) {
throw new Error(`Health check failed: ${response.statusText}`);
}
return response.json();
}
export async function getModels(): Promise<ModelsResponse> {
const response = await fetch(`${API_BASE}/v1/models`);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
return response.json();
}
export async function sendCompletion(
request: ChatCompletionRequest
): Promise<ChatCompletionResponse> {
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, stream: false }),
});
if (!response.ok) {
throw new Error(`Completion failed: ${response.statusText}`);
}
return response.json();
}
export async function* streamCompletion(
request: ChatCompletionRequest
): AsyncGenerator<string, void, unknown> {
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, stream: true }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Completion failed: ${response.statusText} - ${error}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') return;
try {
const parsed: StreamChunk = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content;
if (content) yield content;
} catch {
// Ignore malformed JSON chunks
}
}
}
// Process remaining buffer
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') return;
try {
const parsed: StreamChunk = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content;
if (content) yield content;
} catch {
// Ignore malformed JSON chunks
}
}
}
} finally {
reader.releaseLock();
}
}

View file

@ -0,0 +1,77 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
let input = $state('');
let textareaEl: HTMLTextAreaElement | undefined = $state();
function handleSubmit() {
if (!input.trim() || chatStore.isStreaming) return;
chatStore.sendMessage(input);
input = '';
if (textareaEl) {
textareaEl.style.height = 'auto';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function autoResize() {
if (textareaEl) {
textareaEl.style.height = 'auto';
textareaEl.style.height = Math.min(textareaEl.scrollHeight, 200) + 'px';
}
}
</script>
<div class="border-t p-4" style="border-color: var(--color-border);">
<div
class="flex items-end gap-3 rounded-xl border p-2"
style="background-color: var(--color-surface); border-color: var(--color-border);"
>
<textarea
bind:this={textareaEl}
bind:value={input}
onkeydown={handleKeydown}
oninput={autoResize}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
disabled={chatStore.isStreaming}
rows="1"
class="max-h-[200px] min-h-[44px] flex-1 resize-none bg-transparent px-2 py-2 text-sm outline-none placeholder:text-gray-500 disabled:opacity-50"
></textarea>
{#if chatStore.isStreaming}
<button
onclick={() => chatStore.stopStreaming()}
aria-label="Stop streaming"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg transition-colors"
style="background-color: var(--color-error);"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
</button>
{:else}
<button
onclick={handleSubmit}
disabled={!input.trim()}
aria-label="Send message"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg transition-colors disabled:opacity-50"
style="background-color: var(--color-primary);"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
{/if}
</div>
</div>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { ChatMessage } from '$lib/types';
import { marked } from 'marked';
interface Props {
message: ChatMessage;
}
let { message }: Props = $props();
const renderedContent = $derived(
message.role === 'assistant' ? marked.parse(message.content, { async: false }) : message.content
);
function formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
<div class="flex {message.role === 'user' ? 'justify-end' : 'justify-start'}">
<div
class="max-w-[80%] rounded-2xl px-4 py-3 {message.role === 'user'
? 'rounded-br-md'
: 'rounded-bl-md'}"
style="background-color: {message.role === 'user'
? 'var(--color-primary)'
: 'var(--color-surface)'};"
>
{#if message.role === 'assistant'}
<div class="prose prose-invert text-sm">
{#if message.isStreaming && message.content === ''}
<div class="flex items-center gap-1">
<span class="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"
></span>
<span class="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"
></span>
<span class="h-2 w-2 animate-bounce rounded-full bg-current"></span>
</div>
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderedContent}
{#if message.isStreaming}
<span class="inline-block h-4 w-1 animate-pulse bg-current"></span>
{/if}
{/if}
</div>
{:else}
<p class="text-sm whitespace-pre-wrap">{message.content}</p>
{/if}
<div
class="mt-2 flex items-center gap-2 text-xs"
style="color: {message.role === 'user' ? 'rgba(255,255,255,0.7)' : 'var(--color-text-muted)'};"
>
<span>{formatTime(message.timestamp)}</span>
{#if message.model}
<span>·</span>
<span>{message.model.split('/').pop()}</span>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import MessageBubble from './MessageBubble.svelte';
let scrollContainer: HTMLDivElement | undefined = $state();
$effect(() => {
// Scroll to bottom when messages change
if (chatStore.messages.length && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
});
</script>
<div bind:this={scrollContainer} class="flex-1 overflow-y-auto p-4">
{#if chatStore.messages.length === 0}
<div class="flex h-full flex-col items-center justify-center">
<div
class="mb-4 rounded-full p-4"
style="background-color: var(--color-surface);"
>
<svg class="h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="color: var(--color-text-muted);">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h2 class="mb-2 text-lg font-medium">Start a conversation</h2>
<p class="max-w-md text-center text-sm" style="color: var(--color-text-muted);">
Select a model from the sidebar and send a message to begin testing the mana-llm service.
</p>
</div>
{:else}
<div class="space-y-4">
{#each chatStore.messages as message (message.id)}
<MessageBubble {message} />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import { getHealth } from '$lib/api/llm';
import { onMount } from 'svelte';
let healthStatus = $state<'loading' | 'healthy' | 'error'>('loading');
let healthDetails = $state<string>('');
async function checkHealth() {
try {
const health = await getHealth();
healthStatus = health.status === 'healthy' ? 'healthy' : 'error';
healthDetails = health.version ? `v${health.version}` : '';
} catch {
healthStatus = 'error';
healthDetails = 'Connection failed';
}
}
onMount(() => {
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => clearInterval(interval);
});
</script>
<header
class="flex h-14 items-center justify-between border-b px-4"
style="border-color: var(--color-border); background-color: var(--color-surface);"
>
<div class="flex items-center gap-3">
<svg class="h-8 w-8" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="var(--color-primary)" />
<path d="M8 16l4-8 4 8-4 8-4-8z" fill="white" opacity="0.9" />
<path d="M16 16l4-8 4 8-4 8-4-8z" fill="white" opacity="0.7" />
</svg>
<div>
<h1 class="text-lg font-semibold">LLM Playground</h1>
<p class="text-xs" style="color: var(--color-text-muted);">mana-llm Service</p>
</div>
</div>
<div class="flex items-center gap-2">
<div
class="flex items-center gap-2 rounded-full px-3 py-1 text-sm"
style="background-color: var(--color-bg);"
>
{#if healthStatus === 'loading'}
<div
class="h-2 w-2 animate-pulse rounded-full"
style="background-color: var(--color-warning);"
></div>
<span style="color: var(--color-text-muted);">Checking...</span>
{:else if healthStatus === 'healthy'}
<div class="h-2 w-2 rounded-full" style="background-color: var(--color-success);"></div>
<span style="color: var(--color-success);">Healthy</span>
{#if healthDetails}
<span style="color: var(--color-text-muted);">({healthDetails})</span>
{/if}
{:else}
<div class="h-2 w-2 rounded-full" style="background-color: var(--color-error);"></div>
<span style="color: var(--color-error);">Offline</span>
{/if}
</div>
<button
onclick={checkHealth}
class="rounded p-1.5 transition-colors hover:bg-white/10"
title="Refresh health status"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</header>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import ModelSelector from '$lib/components/settings/ModelSelector.svelte';
import ParameterPanel from '$lib/components/settings/ParameterPanel.svelte';
import SystemPromptEditor from '$lib/components/settings/SystemPromptEditor.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
function handleExport() {
const data = chatStore.exportMessages();
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-export-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleClear() {
if (chatStore.messages.length === 0) return;
if (confirm('Clear all messages?')) {
chatStore.clearMessages();
}
}
</script>
<aside
class="flex w-80 flex-col border-r"
style="border-color: var(--color-border); background-color: var(--color-surface);"
>
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-6">
<ModelSelector />
<ParameterPanel />
<SystemPromptEditor />
</div>
</div>
<div class="border-t p-4" style="border-color: var(--color-border);">
<div class="flex gap-2">
<button
onclick={handleClear}
disabled={chatStore.messages.length === 0}
class="flex flex-1 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50"
style="background-color: var(--color-bg);"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Clear
</button>
<button
onclick={handleExport}
disabled={chatStore.messages.length === 0}
class="flex flex-1 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50"
style="background-color: var(--color-bg);"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
Export
</button>
</div>
</div>
</aside>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { onMount } from 'svelte';
onMount(() => {
modelsStore.loadModels();
});
</script>
<div>
<label for="model-select" class="mb-2 block text-sm font-medium">Model</label>
{#if modelsStore.loading}
<div
class="flex h-10 items-center justify-center rounded-lg"
style="background-color: var(--color-bg);"
>
<span class="text-sm" style="color: var(--color-text-muted);">Loading models...</span>
</div>
{:else if modelsStore.error}
<div class="space-y-2">
<div
class="rounded-lg p-3 text-sm"
style="background-color: rgba(239, 68, 68, 0.1); color: var(--color-error);"
>
{modelsStore.error}
</div>
<button
onclick={() => modelsStore.loadModels()}
class="w-full rounded-lg py-2 text-sm font-medium transition-colors"
style="background-color: var(--color-bg);"
>
Retry
</button>
</div>
{:else}
<select
id="model-select"
bind:value={settingsStore.model}
class="w-full rounded-lg border px-3 py-2 text-sm"
style="background-color: var(--color-bg); border-color: var(--color-border);"
>
{#each modelsStore.groupedModels as group}
<optgroup label={group.label}>
{#each group.models as model}
<option value={model.id}>{model.id.split('/').slice(1).join('/')}</option>
{/each}
</optgroup>
{/each}
</select>
<p class="mt-1.5 text-xs" style="color: var(--color-text-muted);">
{modelsStore.models.length} models available
</p>
{/if}
</div>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
</script>
<div class="space-y-4">
<div>
<div class="mb-2 flex items-center justify-between">
<label for="temperature-slider" class="text-sm font-medium">Temperature</label>
<span class="text-sm" style="color: var(--color-text-muted);"
>{settingsStore.temperature.toFixed(2)}</span
>
</div>
<input
id="temperature-slider"
type="range"
min="0"
max="2"
step="0.01"
bind:value={settingsStore.temperature}
class="h-2 w-full cursor-pointer appearance-none rounded-lg"
style="background: linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) {(settingsStore.temperature /
2) *
100}%, var(--color-bg) {(settingsStore.temperature / 2) * 100}%, var(--color-bg) 100%);"
/>
<div
class="mt-1 flex justify-between text-xs"
style="color: var(--color-text-muted);"
>
<span>Precise</span>
<span>Creative</span>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<label for="max-tokens-slider" class="text-sm font-medium">Max Tokens</label>
<span class="text-sm" style="color: var(--color-text-muted);">{settingsStore.maxTokens}</span>
</div>
<input
id="max-tokens-slider"
type="range"
min="256"
max="8192"
step="256"
bind:value={settingsStore.maxTokens}
class="h-2 w-full cursor-pointer appearance-none rounded-lg"
style="background: linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) {((settingsStore.maxTokens -
256) /
(8192 - 256)) *
100}%, var(--color-bg) {((settingsStore.maxTokens - 256) / (8192 - 256)) * 100}%, var(--color-bg) 100%);"
/>
<div
class="mt-1 flex justify-between text-xs"
style="color: var(--color-text-muted);"
>
<span>256</span>
<span>8192</span>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<label for="top-p-slider" class="text-sm font-medium">Top P</label>
<span class="text-sm" style="color: var(--color-text-muted);"
>{settingsStore.topP.toFixed(2)}</span
>
</div>
<input
id="top-p-slider"
type="range"
min="0"
max="1"
step="0.01"
bind:value={settingsStore.topP}
class="h-2 w-full cursor-pointer appearance-none rounded-lg"
style="background: linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) {settingsStore.topP *
100}%, var(--color-bg) {settingsStore.topP * 100}%, var(--color-bg) 100%);"
/>
<div
class="mt-1 flex justify-between text-xs"
style="color: var(--color-text-muted);"
>
<span>Focused</span>
<span>Diverse</span>
</div>
</div>
</div>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
</script>
<div>
<div class="mb-2 flex items-center justify-between">
<label for="system-prompt" class="text-sm font-medium">System Prompt</label>
<button
onclick={() => (settingsStore.systemPrompt = 'You are a helpful AI assistant.')}
class="text-xs transition-colors"
style="color: var(--color-text-muted);"
>
Reset
</button>
</div>
<textarea
id="system-prompt"
bind:value={settingsStore.systemPrompt}
placeholder="Enter system prompt..."
rows="4"
class="w-full resize-none rounded-lg border p-3 text-sm"
style="background-color: var(--color-bg); border-color: var(--color-border);"
></textarea>
<p class="mt-1.5 text-xs" style="color: var(--color-text-muted);">
{settingsStore.systemPrompt.length} characters
</p>
</div>

View file

@ -0,0 +1,141 @@
import type { ChatMessage, Message } from '$lib/types';
import { streamCompletion } from '$lib/api/llm';
import { settingsStore } from './settings.svelte';
function generateId(): string {
return crypto.randomUUID();
}
function createChatStore() {
let messages = $state<ChatMessage[]>([]);
let isStreaming = $state(false);
let abortController = $state<AbortController | null>(null);
return {
get messages() {
return messages;
},
get isStreaming() {
return isStreaming;
},
async sendMessage(content: string) {
if (isStreaming || !content.trim()) return;
// Add user message
const userMessage: ChatMessage = {
id: generateId(),
role: 'user',
content: content.trim(),
timestamp: new Date(),
};
messages.push(userMessage);
// Create assistant message placeholder
const assistantMessage: ChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
timestamp: new Date(),
model: settingsStore.model,
isStreaming: true,
};
messages.push(assistantMessage);
// Build messages for API
const apiMessages: Message[] = [];
if (settingsStore.systemPrompt.trim()) {
apiMessages.push({
role: 'system',
content: settingsStore.systemPrompt,
});
}
for (const msg of messages) {
if (msg.role === 'user' || (msg.role === 'assistant' && !msg.isStreaming)) {
apiMessages.push({
role: msg.role,
content: msg.content,
});
}
}
// Start streaming
isStreaming = true;
abortController = new AbortController();
try {
const stream = streamCompletion({
model: settingsStore.model,
messages: apiMessages,
temperature: settingsStore.temperature,
max_tokens: settingsStore.maxTokens,
top_p: settingsStore.topP,
stream: true,
});
for await (const chunk of stream) {
// Find and update the assistant message
const idx = messages.findIndex((m) => m.id === assistantMessage.id);
if (idx !== -1) {
messages[idx].content += chunk;
}
}
// Mark streaming complete
const idx = messages.findIndex((m) => m.id === assistantMessage.id);
if (idx !== -1) {
messages[idx].isStreaming = false;
messages[idx].timestamp = new Date();
}
} catch (error) {
// Update message with error
const idx = messages.findIndex((m) => m.id === assistantMessage.id);
if (idx !== -1) {
messages[idx].content = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
messages[idx].isStreaming = false;
}
} finally {
isStreaming = false;
abortController = null;
}
},
stopStreaming() {
if (abortController) {
abortController.abort();
}
},
clearMessages() {
messages = [];
isStreaming = false;
},
exportMessages(): string {
return JSON.stringify(
{
exported: new Date().toISOString(),
settings: {
model: settingsStore.model,
temperature: settingsStore.temperature,
maxTokens: settingsStore.maxTokens,
topP: settingsStore.topP,
systemPrompt: settingsStore.systemPrompt,
},
messages: messages.map((m) => ({
role: m.role,
content: m.content,
timestamp: m.timestamp,
model: m.model,
})),
},
null,
2
);
},
};
}
export const chatStore = createChatStore();

View file

@ -0,0 +1,88 @@
import type { Model, Provider } from '$lib/types';
import { getModels } from '$lib/api/llm';
interface GroupedModels {
provider: Provider;
label: string;
models: Model[];
}
function createModelsStore() {
let models = $state<Model[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
const groupedModels = $derived.by(() => {
const groups: Record<Provider, Model[]> = {
ollama: [],
openrouter: [],
groq: [],
together: [],
};
for (const model of models) {
const id = model.id;
if (id.startsWith('ollama/')) {
groups.ollama.push(model);
} else if (id.startsWith('openrouter/')) {
groups.openrouter.push(model);
} else if (id.startsWith('groq/')) {
groups.groq.push(model);
} else if (id.startsWith('together/')) {
groups.together.push(model);
}
}
const result: GroupedModels[] = [];
const labels: Record<Provider, string> = {
ollama: 'Ollama (Local)',
openrouter: 'OpenRouter',
groq: 'Groq',
together: 'Together AI',
};
for (const [provider, providerModels] of Object.entries(groups)) {
if (providerModels.length > 0) {
result.push({
provider: provider as Provider,
label: labels[provider as Provider],
models: providerModels.sort((a, b) => a.id.localeCompare(b.id)),
});
}
}
return result;
});
return {
get models() {
return models;
},
get loading() {
return loading;
},
get error() {
return error;
},
get groupedModels() {
return groupedModels;
},
async loadModels() {
loading = true;
error = null;
try {
const response = await getModels();
models = response.data;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load models';
models = [];
} finally {
loading = false;
}
},
};
}
export const modelsStore = createModelsStore();

View file

@ -0,0 +1,84 @@
import type { Settings } from '$lib/types';
import { browser } from '$app/environment';
const STORAGE_KEY = 'llm-playground-settings';
const defaultSettings: Settings = {
model: 'ollama/llama3.2:3b',
temperature: 0.7,
maxTokens: 2048,
topP: 1.0,
systemPrompt: 'You are a helpful AI assistant.',
};
function loadSettings(): Settings {
if (!browser) return defaultSettings;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) };
}
} catch {
// Ignore parse errors
}
return defaultSettings;
}
function saveSettings(settings: Settings): void {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
function createSettingsStore() {
let settings = $state<Settings>(loadSettings());
return {
get model() {
return settings.model;
},
set model(value: string) {
settings.model = value;
saveSettings(settings);
},
get temperature() {
return settings.temperature;
},
set temperature(value: number) {
settings.temperature = value;
saveSettings(settings);
},
get maxTokens() {
return settings.maxTokens;
},
set maxTokens(value: number) {
settings.maxTokens = value;
saveSettings(settings);
},
get topP() {
return settings.topP;
},
set topP(value: number) {
settings.topP = value;
saveSettings(settings);
},
get systemPrompt() {
return settings.systemPrompt;
},
set systemPrompt(value: string) {
settings.systemPrompt = value;
saveSettings(settings);
},
reset() {
settings = { ...defaultSettings };
saveSettings(settings);
},
};
}
export const settingsStore = createSettingsStore();

View file

@ -0,0 +1,88 @@
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatCompletionRequest {
model: string;
messages: Message[];
temperature?: number;
max_tokens?: number;
top_p?: number;
stream?: boolean;
}
export interface ChatCompletionChoice {
index: number;
message: Message;
finish_reason: string | null;
}
export interface ChatCompletionResponse {
id: string;
object: string;
created: number;
model: string;
choices: ChatCompletionChoice[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface StreamDelta {
role?: string;
content?: string;
}
export interface StreamChoice {
index: number;
delta: StreamDelta;
finish_reason: string | null;
}
export interface StreamChunk {
id: string;
object: string;
created: number;
model: string;
choices: StreamChoice[];
}
export interface Model {
id: string;
object: string;
created: number;
owned_by: string;
}
export interface ModelsResponse {
object: string;
data: Model[];
}
export interface HealthResponse {
status: string;
timestamp: string;
version?: string;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
model?: string;
isStreaming?: boolean;
}
export interface Settings {
model: string;
temperature: number;
maxTokens: number;
topP: number;
systemPrompt: string;
}
export type Provider = 'ollama' | 'openrouter' | 'groq' | 'together';

View file

@ -0,0 +1,13 @@
<script lang="ts">
import '../app.css';
interface Props {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<div class="flex h-screen flex-col" style="background-color: var(--color-bg);">
{@render children()}
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import Header from '$lib/components/layout/Header.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
</script>
<svelte:head>
<title>LLM Playground | mana-llm</title>
</svelte:head>
<Header />
<div class="flex flex-1 overflow-hidden">
<Sidebar />
<main class="flex flex-1 flex-col" style="background-color: var(--color-bg);">
<MessageList />
<ChatInput />
</main>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
});