refactor: move llm-playground from services/ to apps/playground/

The LLM playground is a SvelteKit web app (frontend), not a backend
microservice. Moving it to apps/ follows the monorepo convention where
all user-facing apps live under apps/.

- Moved services/llm-playground/ → apps/playground/apps/web/
- Renamed package from @mana-llm/playground to @playground/web
- Updated Dockerfile paths for new location
- Updated docker-compose.macmini.yml build context
- Removed unused concurrently dependency
- Added parent package.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 11:25:54 +01:00
parent 40718a7554
commit 71b9339310
41 changed files with 527 additions and 530 deletions

View file

@ -0,0 +1,9 @@
node_modules
.svelte-kit
.git
*.md
.env*
!.env.example
dist
coverage
.DS_Store

7
apps/playground/apps/web/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,74 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
ARG PUBLIC_MANA_LLM_URL=http://mana-llm:3025
# Set as environment variables for build
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
ENV PUBLIC_MANA_LLM_URL=$PUBLIC_MANA_LLM_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by llm-playground
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
# Copy llm-playground service
COPY apps/playground/apps/web ./apps/playground/apps/web
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/playground/apps/web
RUN pnpm exec svelte-kit sync
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/playground/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/playground/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/playground/apps/web/build ./build
COPY --from=builder /app/apps/playground/apps/web/package.json ./
# Expose port
EXPOSE 5190
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5190
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5190/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,33 @@
{
"name": "@playground/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev --port 5190",
"dev:full": "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": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"marked": "^17.0.0",
"svelte-i18n": "^4.0.1"
}
}

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);
}

18
apps/playground/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
/// <reference types="@sveltejs/kit" />
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface Window {
__PUBLIC_MANA_CORE_AUTH_URL__?: string;
__PUBLIC_MANA_LLM_URL__?: string;
}
}
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,18 @@
import type { Handle } from '@sveltejs/kit';
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_MANA_LLM_URL_CLIENT =
process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_MANA_LLM_URL__ = "${PUBLIC_MANA_LLM_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -0,0 +1,122 @@
import type {
ChatCompletionRequest,
ChatCompletionResponse,
HealthResponse,
ModelsResponse,
StreamChunk,
} from '$lib/types';
import { env } from '$env/dynamic/public';
import { browser } from '$app/environment';
function getApiBase(): string {
if (browser) {
return (
(window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }).__PUBLIC_MANA_LLM_URL__ ||
env.PUBLIC_MANA_LLM_URL ||
'http://localhost:3025'
);
}
return env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025';
}
export async function getHealth(): Promise<HealthResponse> {
const response = await fetch(`${getApiBase()}/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(`${getApiBase()}/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(`${getApiBase()}/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(`${getApiBase()}/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,88 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import { comparisonStore } from '$lib/stores/comparison.svelte';
let input = $state('');
let textareaEl: HTMLTextAreaElement | undefined = $state();
const isComparisonReady = $derived(
comparisonStore.comparisonMode && comparisonStore.selectedModels.length >= 2
);
function handleSubmit() {
if (!input.trim() || chatStore.isStreaming) return;
if (isComparisonReady) {
chatStore.sendComparisonMessage(input);
} else {
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,31 @@
<script lang="ts">
import type { ComparisonMessage } from '$lib/types';
import ComparisonResponseCard from '../comparison/ComparisonResponseCard.svelte';
let { message }: { message: ComparisonMessage } = $props();
</script>
<div class="space-y-4">
<!-- User Message -->
<div class="flex justify-end">
<div class="max-w-[80%] rounded-lg p-3" style="background-color: var(--color-primary);">
<p class="whitespace-pre-wrap text-white">{message.userContent}</p>
</div>
</div>
<!-- Comparison Label -->
<div class="flex items-center gap-2">
<div class="h-px flex-1" style="background-color: var(--color-border);"></div>
<span class="text-xs font-medium" style="color: var(--color-text-muted);">
Comparing {message.responses.length} models
</span>
<div class="h-px flex-1" style="background-color: var(--color-border);"></div>
</div>
<!-- Comparison Responses Grid -->
<div class="flex gap-4 overflow-x-auto pb-2">
{#each message.responses as response (response.modelId)}
<ComparisonResponseCard {response} />
{/each}
</div>
</div>

View file

@ -0,0 +1,64 @@
<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,52 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import type { ChatMessage, ComparisonMessage } from '$lib/types';
import MessageBubble from './MessageBubble.svelte';
import ComparisonMessageBubble from './ComparisonMessageBubble.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)}
{#if message.role === 'comparison'}
<ComparisonMessageBubble message={message as ComparisonMessage} />
{:else}
<MessageBubble message={message as ChatMessage} />
{/if}
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { ComparisonResponse } from '$lib/types';
let { response }: { response: ComparisonResponse } = $props();
const modelName = $derived(response.modelId.split('/').pop() || response.modelId);
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
</script>
<div
class="flex min-w-[280px] flex-1 flex-col rounded-lg border p-4"
style="background-color: var(--color-surface); border-color: var(--color-border);"
>
<!-- Header -->
<div
class="mb-3 flex items-center justify-between border-b pb-2"
style="border-color: var(--color-border);"
>
<span class="truncate text-sm font-medium" style="color: var(--color-text);">
{modelName}
</span>
{#if response.isStreaming}
<span class="animate-pulse rounded bg-blue-600 px-2 py-0.5 text-xs text-white">
Streaming...
</span>
{:else if response.error}
<span class="rounded bg-red-600 px-2 py-0.5 text-xs text-white"> Error </span>
{:else}
<span class="rounded bg-green-600 px-2 py-0.5 text-xs text-white"> Done </span>
{/if}
</div>
<!-- Content -->
<div class="mb-3 min-h-[100px] flex-1 overflow-y-auto text-sm" style="color: var(--color-text);">
{#if response.error}
<p class="text-red-400">{response.error}</p>
{:else}
<pre class="whitespace-pre-wrap font-sans">{response.content}</pre>
{#if response.isStreaming}
<span class="ml-1 inline-block h-4 w-2 animate-pulse bg-blue-500"></span>
{/if}
{/if}
</div>
<!-- Metrics -->
{#if response.metrics && !response.isStreaming}
<div
class="flex gap-4 border-t pt-2 text-xs"
style="border-color: var(--color-border); color: var(--color-text-muted);"
>
<span>{formatDuration(response.metrics.durationMs)}</span>
<span>~{response.metrics.completionTokens} tokens</span>
{#if response.metrics.tokensPerSecond > 0}
<span>{response.metrics.tokensPerSecond.toFixed(1)} t/s</span>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import type { ModelWithModality, Modality } from '$lib/types';
import { comparisonStore } from '$lib/stores/comparison.svelte';
import ModelModalityFilter from './ModelModalityFilter.svelte';
let { models }: { models: ModelWithModality[] } = $props();
let selectedModality = $state<Modality>('text');
const filteredModels = $derived(models.filter((m) => m.modality === selectedModality));
function getModelDisplayName(modelId: string): string {
const parts = modelId.split('/');
return parts.length > 1 ? parts.slice(1).join('/') : modelId;
}
</script>
<div class="border-t p-4" style="border-color: var(--color-border);">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold" style="color: var(--color-text);">Model Comparison</h3>
<button
onclick={() => comparisonStore.toggleComparisonMode()}
class="rounded px-2 py-1 text-xs transition-colors"
class:bg-blue-600={comparisonStore.comparisonMode}
class:text-white={comparisonStore.comparisonMode}
style={!comparisonStore.comparisonMode
? 'background-color: var(--color-bg); color: var(--color-text-muted);'
: ''}
>
{comparisonStore.comparisonMode ? 'Active' : 'Off'}
</button>
</div>
{#if comparisonStore.comparisonMode}
<ModelModalityFilter {models} bind:selectedModality />
<div class="max-h-48 space-y-1 overflow-y-auto">
{#each filteredModels as model}
{@const isSelected = comparisonStore.isModelSelected(model.id)}
{@const isDisabled = !isSelected && !comparisonStore.canAddModel()}
<label
class="flex cursor-pointer items-center gap-2 rounded p-2 transition-colors hover:bg-zinc-800"
class:opacity-50={isDisabled}
class:cursor-not-allowed={isDisabled}
title={model.description || ''}
>
<input
type="checkbox"
checked={isSelected}
onchange={() => comparisonStore.toggleModel(model.id)}
disabled={isDisabled}
class="rounded"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-sm" style="color: var(--color-text);">
{getModelDisplayName(model.id)}
</span>
{#if model.description}
<span class="block truncate text-xs" style="color: var(--color-text-muted);">
{model.description}
</span>
{/if}
</div>
</label>
{/each}
</div>
<p class="mt-2 text-xs" style="color: var(--color-text-muted);">
{comparisonStore.selectedModels.length}/{comparisonStore.maxModels} models selected
</p>
{#if comparisonStore.selectedModels.length > 0}
<button
onclick={() => comparisonStore.clearSelection()}
class="mt-2 w-full rounded px-2 py-1 text-xs transition-colors"
style="background-color: var(--color-bg); color: var(--color-text-muted);"
>
Clear Selection
</button>
{/if}
{/if}
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import type { Modality, ModelWithModality } from '$lib/types';
let {
models,
selectedModality = $bindable('text'),
}: {
models: ModelWithModality[];
selectedModality: Modality;
} = $props();
const modalities: { value: Modality; label: string; icon: string }[] = [
{ value: 'text', label: 'Text', icon: 'T' },
{ value: 'vision', label: 'Vision', icon: 'V' },
{ value: 'code', label: 'Code', icon: 'C' },
];
const modelCounts = $derived(
modalities.map((m) => ({
...m,
count: models.filter((model) => model.modality === m.value).length,
}))
);
</script>
<div class="mb-3 flex gap-2">
{#each modelCounts as mod}
<button
onclick={() => (selectedModality = mod.value)}
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
class:bg-blue-600={selectedModality === mod.value}
class:text-white={selectedModality === mod.value}
style={selectedModality !== mod.value
? 'background-color: var(--color-bg); color: var(--color-text-muted);'
: ''}
>
<span class="mr-1 font-bold">{mod.icon}</span>
{mod.label}
<span class="ml-1 opacity-70">({mod.count})</span>
</button>
{/each}
</div>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { getHealth } from '$lib/api/llm';
import { authStore } from '$lib/stores/auth.svelte';
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>
<button
onclick={() => authStore.signOut()}
class="rounded px-3 py-1.5 text-sm transition-colors hover:bg-white/10"
style="color: var(--color-text-muted);"
title="Abmelden"
>
Abmelden
</button>
</div>
</header>

View file

@ -0,0 +1,78 @@
<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 ModelComparisonSelector from '$lib/components/comparison/ModelComparisonSelector.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { modelsStore } from '$lib/stores/models.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>
<ModelComparisonSelector models={modelsStore.modelsWithModality} />
<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,66 @@
<script lang="ts">
import { modelsStore, MODEL_METADATA } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { onMount } from 'svelte';
onMount(() => {
modelsStore.loadModels();
});
// Get description for currently selected model
const selectedModelDescription = $derived(() => {
const modelName = settingsStore.model.includes('/')
? settingsStore.model.split('/').slice(1).join('/')
: settingsStore.model;
return MODEL_METADATA[modelName]?.description;
});
</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>
<div class="mt-1.5 text-xs" style="color: var(--color-text-muted);">
{#if selectedModelDescription()}
<p class="mb-0.5">{selectedModelDescription()}</p>
{/if}
<p>{modelsStore.models.length} models available</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,79 @@
<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,147 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { initializeWebAuth, type AuthService, type UserData } from '@manacore/shared-auth';
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
let _authService: AuthService | null = null;
function getAuthUrl(): string {
if (!browser) return '';
return (
(window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__ ||
import.meta.env.PUBLIC_MANA_CORE_AUTH_URL ||
'http://localhost:3001'
);
}
function getLlmUrl(): string {
if (!browser) return '';
return (
(window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }).__PUBLIC_MANA_LLM_URL__ ||
import.meta.env.PUBLIC_MANA_LLM_URL ||
'http://localhost:3025'
);
}
function getAuthService(): AuthService | null {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getLlmUrl(),
});
_authService = auth.authService;
}
return _authService;
}
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get initialized() {
return initialized;
},
get isAuthenticated() {
return !!user;
},
async initialize() {
if (initialized || !browser) return;
loading = true;
try {
const authService = getAuthService();
if (authService) {
const currentUser = await authService.getUserFromToken();
user = currentUser;
}
} catch (error) {
console.error('Auth initialization failed:', error);
user = null;
} finally {
loading = false;
initialized = true;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) throw new Error('Auth not initialized');
const result = await authService.signIn(email, password);
if (result.success) {
const currentUser = await authService.getUserFromToken();
user = currentUser;
}
return result;
},
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available', needsVerification: false };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.signUp(email, password, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
async signOut() {
const authService = getAuthService();
if (authService) {
await authService.signOut();
}
user = null;
goto('/login');
},
async getValidToken(): Promise<string | null> {
const authService = getAuthService();
if (!authService) return null;
return authService.getAppToken();
},
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available' };
}
try {
const sourceAppUrl = typeof window !== 'undefined' ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -0,0 +1,205 @@
import type { AnyMessage, ChatMessage, ComparisonMessage, Message } from '$lib/types';
import { streamCompletion } from '$lib/api/llm';
import { settingsStore } from './settings.svelte';
import { comparisonStore } from './comparison.svelte';
function generateId(): string {
return crypto.randomUUID();
}
function createChatStore() {
let messages = $state<AnyMessage[]>([]);
let isStreaming = $state(false);
let abortController = $state<AbortController | null>(null);
// Helper to extract conversation history for API calls
function getConversationHistory(): Message[] {
const history: Message[] = [];
for (const msg of messages) {
if (msg.role === 'user') {
history.push({ role: 'user', content: (msg as ChatMessage).content });
} else if (msg.role === 'assistant' && !(msg as ChatMessage).isStreaming) {
history.push({ role: 'assistant', content: (msg as ChatMessage).content });
}
// Skip comparison messages in history for now
}
return history;
}
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 === 'comparison') continue; // Skip comparison messages
const chatMsg = msg as ChatMessage;
if (chatMsg.role === 'user' || (chatMsg.role === 'assistant' && !chatMsg.isStreaming)) {
apiMessages.push({
role: chatMsg.role,
content: chatMsg.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] as ChatMessage).content += chunk;
}
}
// Mark streaming complete
const idx = messages.findIndex((m) => m.id === assistantMessage.id);
if (idx !== -1) {
(messages[idx] as ChatMessage).isStreaming = false;
(messages[idx] as ChatMessage).timestamp = new Date();
}
} catch (error) {
// Update message with error
const idx = messages.findIndex((m) => m.id === assistantMessage.id);
if (idx !== -1) {
(messages[idx] as ChatMessage).content =
`Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
(messages[idx] as ChatMessage).isStreaming = false;
}
} finally {
isStreaming = false;
abortController = null;
}
},
async sendComparisonMessage(content: string) {
if (isStreaming || !content.trim()) return;
if (comparisonStore.selectedModels.length < 2) return;
const comparisonMsg: ComparisonMessage = {
id: generateId(),
role: 'comparison',
userContent: content.trim(),
responses: [],
timestamp: new Date(),
};
messages = [...messages, comparisonMsg];
isStreaming = true;
try {
const history = getConversationHistory();
await comparisonStore.compareModels(
content.trim(),
comparisonStore.selectedModels,
history,
(responses) => {
const idx = messages.findIndex((m) => m.id === comparisonMsg.id);
if (idx !== -1) {
(messages[idx] as ComparisonMessage).responses = responses;
messages = [...messages];
}
}
);
} finally {
isStreaming = false;
}
},
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) => {
if (m.role === 'comparison') {
const compMsg = m as ComparisonMessage;
return {
role: 'comparison',
userContent: compMsg.userContent,
responses: compMsg.responses,
timestamp: compMsg.timestamp,
};
}
const chatMsg = m as ChatMessage;
return {
role: chatMsg.role,
content: chatMsg.content,
timestamp: chatMsg.timestamp,
model: chatMsg.model,
};
}),
},
null,
2
);
},
};
}
export const chatStore = createChatStore();

View file

@ -0,0 +1,137 @@
import { streamCompletion } from '$lib/api/llm';
import type { ComparisonResponse, ChatCompletionRequest, Message } from '$lib/types';
import { settingsStore } from './settings.svelte';
function createComparisonStore() {
let comparisonMode = $state(false);
let selectedModels = $state<string[]>([]);
const maxModels = 4;
return {
get comparisonMode() {
return comparisonMode;
},
get selectedModels() {
return selectedModels;
},
get maxModels() {
return maxModels;
},
toggleComparisonMode() {
comparisonMode = !comparisonMode;
if (!comparisonMode) {
selectedModels = [];
}
},
setComparisonMode(value: boolean) {
comparisonMode = value;
if (!value) {
selectedModels = [];
}
},
toggleModel(modelId: string) {
if (selectedModels.includes(modelId)) {
selectedModels = selectedModels.filter((m) => m !== modelId);
} else if (selectedModels.length < maxModels) {
selectedModels = [...selectedModels, modelId];
}
},
isModelSelected(modelId: string): boolean {
return selectedModels.includes(modelId);
},
clearSelection() {
selectedModels = [];
},
canAddModel(): boolean {
return selectedModels.length < maxModels;
},
async compareModels(
content: string,
models: string[],
conversationHistory: Message[],
onUpdate: (responses: ComparisonResponse[]) => void
): Promise<ComparisonResponse[]> {
const responses: ComparisonResponse[] = models.map((modelId) => ({
modelId,
content: '',
isStreaming: true,
startTime: Date.now(),
}));
onUpdate([...responses]);
// Build base messages including history
const baseMessages: Message[] = [];
if (settingsStore.systemPrompt.trim()) {
baseMessages.push({
role: 'system',
content: settingsStore.systemPrompt,
});
}
// Add conversation history
baseMessages.push(...conversationHistory);
// Add current user message
baseMessages.push({
role: 'user',
content,
});
// Start parallel streams for all models
const streamPromises = models.map(async (modelId, index) => {
const request: ChatCompletionRequest = {
model: modelId,
messages: baseMessages,
temperature: settingsStore.temperature,
max_tokens: settingsStore.maxTokens,
top_p: settingsStore.topP,
stream: true,
};
try {
let tokenCount = 0;
for await (const chunk of streamCompletion(request)) {
responses[index].content += chunk;
tokenCount++;
onUpdate([...responses]);
}
responses[index].isStreaming = false;
responses[index].endTime = Date.now();
const durationMs = responses[index].endTime! - responses[index].startTime;
// Estimate tokens (rough approximation based on whitespace-split words)
const estimatedTokens = responses[index].content.split(/\s+/).length;
responses[index].metrics = {
promptTokens: 0, // Not available from stream
completionTokens: estimatedTokens,
totalTokens: estimatedTokens,
durationMs,
tokensPerSecond: durationMs > 0 ? (estimatedTokens / durationMs) * 1000 : 0,
};
} catch (error) {
responses[index].isStreaming = false;
responses[index].error = error instanceof Error ? error.message : 'Unknown error';
responses[index].endTime = Date.now();
}
onUpdate([...responses]);
});
await Promise.all(streamPromises);
return responses;
},
};
}
export const comparisonStore = createComparisonStore();

View file

@ -0,0 +1,196 @@
import type { Model, ModelWithModality, Modality, Provider } from '$lib/types';
import { getModels } from '$lib/api/llm';
/**
* Model metadata configuration
* Add new models here when installing them on the server
* See: docs/OLLAMA_MODELS.md for instructions
*/
export const MODEL_METADATA: Record<string, { description: string; modality: Modality }> = {
// Text Models - General Purpose
'gemma3:4b': {
description: 'Fast general-purpose model (~53 t/s)',
modality: 'text',
},
'gemma3:12b': {
description: 'Balanced quality & speed (~30 t/s)',
modality: 'text',
},
'gemma3:27b': {
description: 'Best quality, slower (~15 t/s)',
modality: 'text',
},
'phi3.5:latest': {
description: 'Microsoft Phi-3.5 - compact & efficient',
modality: 'text',
},
'ministral-3:3b': {
description: 'Mistral Mini - fast for simple tasks',
modality: 'text',
},
// Vision Models
'llava:7b': {
description: 'Image understanding & description',
modality: 'vision',
},
'qwen3-vl:4b': {
description: 'Qwen Vision-Language model',
modality: 'vision',
},
'deepseek-ocr:latest': {
description: 'OCR & document understanding',
modality: 'vision',
},
// Code Models
'qwen2.5-coder:7b': {
description: 'Code generation & completion (7B)',
modality: 'code',
},
'qwen2.5-coder:14b': {
description: 'Advanced code generation (14B)',
modality: 'code',
},
};
/**
* Detect modality from model ID
* First checks MODEL_METADATA, then falls back to pattern matching
*/
function detectModality(modelId: string): Modality {
const id = modelId.toLowerCase();
// Extract model name from provider prefix (e.g., "ollama/gemma3:4b" -> "gemma3:4b")
const modelName = id.includes('/') ? id.split('/').slice(1).join('/') : id;
// Check metadata first
if (MODEL_METADATA[modelName]) {
return MODEL_METADATA[modelName].modality;
}
// Vision models (pattern matching fallback)
if (
id.includes('llava') ||
id.includes('vision') ||
id.includes('-vl') ||
id.includes('ocr') ||
id.includes('moondream')
) {
return 'vision';
}
// Code models (pattern matching fallback)
if (id.includes('coder') || id.includes('codellama') || id.includes('starcoder')) {
return 'code';
}
// Default to text
return 'text';
}
/**
* Get model description from metadata
*/
function getModelDescription(modelId: string): string | undefined {
const modelName = modelId.includes('/') ? modelId.split('/').slice(1).join('/') : modelId;
return MODEL_METADATA[modelName]?.description;
}
interface GroupedModels {
provider: Provider;
label: string;
models: Model[];
}
function createModelsStore() {
let models = $state<Model[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Models with modality information for comparison
const modelsWithModality = $derived<ModelWithModality[]>(
models.map((model) => ({
...model,
modality: detectModality(model.id),
description: getModelDescription(model.id),
}))
);
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;
},
get modelsWithModality() {
return modelsWithModality;
},
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/gemma3:4b',
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,125 @@
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';
// Modality types for model comparison
export type Modality = 'text' | 'vision' | 'code';
export interface ModelWithModality extends Model {
modality: Modality;
description?: string;
}
// Comparison response from a single model
export interface ComparisonResponse {
modelId: string;
content: string;
isStreaming: boolean;
startTime: number;
endTime?: number;
metrics?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
durationMs: number;
tokensPerSecond: number;
};
error?: string;
}
// Comparison message containing multiple model responses
export interface ComparisonMessage {
id: string;
role: 'comparison';
userContent: string;
responses: ComparisonResponse[];
timestamp: Date;
}
// Union type for all message types
export type AnyMessage = ChatMessage | ComparisonMessage;

View file

@ -0,0 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { PlaygroundLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
// Default to German translations
const translations = $derived(getLoginTranslations('de'));
// Read verification status from query params
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>{translations.title} | LLM Playground</title>
</svelte:head>
<LoginPage
appName="LLM Playground"
logo={PlaygroundLogo}
primaryColor="#06b6d4"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#ecfeff"
darkBackground="#083344"
{translations}
{verified}
{initialEmail}
/>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { PlaygroundLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
// Default to German translations
const translations = $derived(getRegisterTranslations('de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>{translations.title} | LLM Playground</title>
</svelte:head>
<RegisterPage
appName="LLM Playground"
logo={PlaygroundLogo}
primaryColor="#06b6d4"
onSignUp={handleSignUp}
onResendVerification={handleResendVerification}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#ecfeff"
darkBackground="#083344"
{translations}
/>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
let isChecking = $state(true);
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
const currentPath = $page.url.pathname;
goto(`/login?redirectTo=${encodeURIComponent(currentPath)}`);
return;
}
isChecking = false;
});
</script>
{#if isChecking}
<div
class="min-h-screen flex items-center justify-center"
style="background-color: var(--color-bg);"
>
<div
class="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2"
style="border-color: var(--color-primary);"
></div>
</div>
{:else}
{@render children()}
{/if}

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>

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,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'healthy',
service: 'llm-playground',
timestamp: new Date().toISOString(),
});
};

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()],
});

View file

@ -0,0 +1,7 @@
{
"name": "playground",
"private": true,
"scripts": {
"dev": "turbo run dev"
}
}