feat(llm-playground): add model comparison feature

- Add modality detection (text/vision/code) to models store
- Create comparison store for parallel multi-model streaming
- Add ModelModalityFilter and ModelComparisonSelector components
- Add ComparisonResponseCard with metrics (duration, tokens, t/s)
- Add ComparisonMessageBubble for side-by-side response view
- Integrate comparison mode into ChatInput, MessageList, Sidebar
- Add dev:full script to start mana-llm + playground together
- Add start.sh script for mana-llm Python service
This commit is contained in:
Till-JS 2026-01-31 23:30:16 +01:00
parent 1d88387c52
commit d605366460
14 changed files with 741 additions and 569 deletions

View file

@ -5,12 +5,15 @@
"type": "module",
"scripts": {
"dev": "vite dev --port 5190",
"dev:full": "concurrently -n llm,playground -c blue,green \"npm run start:mana-llm\" \"npm run dev\"",
"start:mana-llm": "cd ../mana-llm && ./start.sh",
"build": "vite build",
"preview": "vite preview",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.4.0",
"concurrently": "^9.1.2",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/vite": "^4.1.7",

View file

@ -1,12 +1,23 @@
<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;
chatStore.sendMessage(input);
if (isComparisonReady) {
chatStore.sendComparisonMessage(input);
} else {
chatStore.sendMessage(input);
}
input = '';
if (textareaEl) {
textareaEl.style.height = 'auto';

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

@ -1,6 +1,8 @@
<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();
@ -15,11 +17,14 @@
<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);">
<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"
@ -36,7 +41,11 @@
{:else}
<div class="space-y-4">
{#each chatStore.messages as message (message.id)}
<MessageBubble {message} />
{#if message.role === 'comparison'}
<ComparisonMessageBubble message={message as ComparisonMessage} />
{:else}
<MessageBubble message={message as ChatMessage} />
{/if}
{/each}
</div>
{/if}

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,74 @@
<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}
>
<input
type="checkbox"
checked={isSelected}
onchange={() => comparisonStore.toggleModel(model.id)}
disabled={isDisabled}
class="rounded"
/>
<span class="truncate text-sm" style="color: var(--color-text);">
{getModelDisplayName(model.id)}
</span>
</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

@ -2,7 +2,9 @@
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();
@ -35,6 +37,8 @@
</div>
</div>
<ModelComparisonSelector models={modelsStore.modelsWithModality} />
<div class="border-t p-4" style="border-color: var(--color-border);">
<div class="flex gap-2">
<button

View file

@ -1,16 +1,31 @@
import type { ChatMessage, Message } from '$lib/types';
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<ChatMessage[]>([]);
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;
@ -53,10 +68,12 @@ function createChatStore() {
}
for (const msg of messages) {
if (msg.role === 'user' || (msg.role === 'assistant' && !msg.isStreaming)) {
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: msg.role,
content: msg.content,
role: chatMsg.role,
content: chatMsg.content,
});
}
}
@ -79,22 +96,23 @@ function createChatStore() {
// Find and update the assistant message
const idx = messages.findIndex((m) => m.id === assistantMessage.id);
if (idx !== -1) {
messages[idx].content += chunk;
(messages[idx] as ChatMessage).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();
(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].content = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
messages[idx].isStreaming = false;
(messages[idx] as ChatMessage).content =
`Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
(messages[idx] as ChatMessage).isStreaming = false;
}
} finally {
isStreaming = false;
@ -102,6 +120,40 @@ function createChatStore() {
}
},
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();
@ -124,12 +176,24 @@ function createChatStore() {
topP: settingsStore.topP,
systemPrompt: settingsStore.systemPrompt,
},
messages: messages.map((m) => ({
role: m.role,
content: m.content,
timestamp: m.timestamp,
model: m.model,
})),
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

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

@ -1,6 +1,30 @@
import type { Model, Provider } from '$lib/types';
import type { Model, ModelWithModality, Modality, Provider } from '$lib/types';
import { getModels } from '$lib/api/llm';
// Detect modality from model ID
function detectModality(modelId: string): Modality {
const id = modelId.toLowerCase();
// Vision models
if (
id.includes('llava') ||
id.includes('vision') ||
id.includes('-vl') ||
id.includes('ocr') ||
id.includes('moondream')
) {
return 'vision';
}
// Code models
if (id.includes('coder') || id.includes('codellama') || id.includes('starcoder')) {
return 'code';
}
// Default to text
return 'text';
}
interface GroupedModels {
provider: Provider;
label: string;
@ -12,6 +36,14 @@ function createModelsStore() {
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),
}))
);
const groupedModels = $derived.by(() => {
const groups: Record<Provider, Model[]> = {
ollama: [],
@ -67,6 +99,9 @@ function createModelsStore() {
get groupedModels() {
return groupedModels;
},
get modelsWithModality() {
return modelsWithModality;
},
async loadModels() {
loading = true;

View file

@ -86,3 +86,40 @@ export interface Settings {
}
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;

28
services/mana-llm/start.sh Executable file
View file

@ -0,0 +1,28 @@
#!/bin/bash
# Start mana-llm service
# Automatically creates venv and installs dependencies if needed
cd "$(dirname "$0")"
# Check if venv exists, create if not
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
fi
# Activate venv
source venv/bin/activate
# Install/update dependencies
pip install -q -r requirements.txt
# Copy .env if not exists
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
cp .env.example .env
echo "Created .env from .env.example"
fi
# Start the service
echo "Starting mana-llm on port 3025..."
exec python -m uvicorn src.main:app --port 3025 --reload