feat(chat): add model comparison feature

Add /compare route to test prompts against multiple Ollama models:
- CompareInput: prompt textarea with temperature/max tokens controls
- ModelResponseCard: displays response with status, metrics, markdown
- ModelResponseGrid: responsive grid layout for side-by-side comparison
- CompareProgress: progress bar with cancel functionality
- Svelte 5 runes-based store for state management
- Add Scales icon to shared-ui navigation
This commit is contained in:
Till-JS 2026-01-27 16:57:56 +01:00
parent 14c83cb4bd
commit ca00672016
8 changed files with 716 additions and 1 deletions

View file

@ -0,0 +1,116 @@
<script lang="ts">
interface Props {
prompt: string;
temperature: number;
maxTokens: number;
isRunning: boolean;
disabled?: boolean;
onPromptChange: (value: string) => void;
onTemperatureChange: (value: number) => void;
onMaxTokensChange: (value: number) => void;
onCompare: () => void;
}
let {
prompt,
temperature,
maxTokens,
isRunning,
disabled = false,
onPromptChange,
onTemperatureChange,
onMaxTokensChange,
onCompare,
}: Props = $props();
const maxTokensOptions = [256, 512, 1024, 2048, 4096];
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (!disabled && !isRunning && prompt.trim()) {
onCompare();
}
}
}
</script>
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
<!-- Prompt Input -->
<textarea
class="w-full min-h-[100px] p-3 rounded-lg border border-border bg-background
text-foreground placeholder:text-muted-foreground resize-y
focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder="Gib deinen Prompt ein... (Strg+Enter zum Starten)"
value={prompt}
oninput={(e) => onPromptChange(e.currentTarget.value)}
onkeydown={handleKeydown}
disabled={isRunning || disabled}
></textarea>
<!-- Controls -->
<div class="flex flex-wrap items-center gap-4 mt-4">
<!-- Temperature -->
<div class="flex items-center gap-2">
<label for="temperature" class="text-sm text-muted-foreground whitespace-nowrap">
Temperatur: {temperature.toFixed(1)}
</label>
<input
id="temperature"
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
oninput={(e) => onTemperatureChange(parseFloat(e.currentTarget.value))}
disabled={isRunning || disabled}
class="w-24 h-2 bg-muted rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-4
[&::-webkit-slider-thumb]:h-4
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:cursor-pointer
disabled:opacity-50"
/>
</div>
<!-- Max Tokens -->
<div class="flex items-center gap-2">
<label for="maxTokens" class="text-sm text-muted-foreground whitespace-nowrap">
Max Tokens:
</label>
<select
id="maxTokens"
value={maxTokens}
onchange={(e) => onMaxTokensChange(parseInt(e.currentTarget.value))}
disabled={isRunning || disabled}
class="px-3 py-1.5 rounded-lg border border-border bg-background text-foreground
text-sm focus:outline-none focus:ring-2 focus:ring-primary/50
disabled:opacity-50"
>
{#each maxTokensOptions as option}
<option value={option}>{option.toLocaleString('de-DE')}</option>
{/each}
</select>
</div>
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Compare Button -->
<button
onclick={onCompare}
disabled={isRunning || disabled || !prompt.trim()}
class="px-6 py-2 rounded-lg bg-primary text-primary-foreground font-medium
hover:bg-primary/90 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isRunning}
Läuft...
{:else}
Vergleichen
{/if}
</button>
</div>
</div>

View file

@ -0,0 +1,43 @@
<script lang="ts">
interface Props {
currentIndex: number;
totalModels: number;
currentModelName: string;
progress: number;
onCancel: () => void;
}
let { currentIndex, totalModels, currentModelName, progress, onCancel }: Props = $props();
</script>
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
<!-- Status Text -->
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-foreground">
Verarbeite Modell <span class="font-medium">{currentIndex + 1}</span> von
<span class="font-medium">{totalModels}</span>:
<span class="text-primary font-medium">{currentModelName}</span>
</p>
<button
onclick={onCancel}
class="px-3 py-1 text-sm rounded-lg border border-border
text-muted-foreground hover:text-foreground hover:bg-muted
transition-colors"
>
Abbrechen
</button>
</div>
<!-- Progress Bar -->
<div class="relative h-2 bg-muted rounded-full overflow-hidden">
<div
class="absolute inset-y-0 left-0 bg-primary rounded-full transition-all duration-300"
style="width: {progress}%"
></div>
</div>
<!-- Percentage -->
<p class="text-xs text-muted-foreground mt-2 text-right">
{Math.round(progress)}%
</p>
</div>

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { marked } from 'marked';
import type { CompareModelResult } from '@chat/types';
interface Props {
result: CompareModelResult;
isActive?: boolean;
}
let { result, isActive = false }: Props = $props();
// Configure marked for safe rendering
marked.setOptions({
breaks: true,
gfm: true,
});
const htmlContent = $derived(result.response ? marked.parse(result.response) : '');
// Calculate tokens per second
const tokensPerSecond = $derived(() => {
if (!result.usage || !result.duration || result.duration === 0) return null;
return (result.usage.completion_tokens / (result.duration / 1000)).toFixed(1);
});
// Status badge config
const statusConfig = $derived(() => {
switch (result.status) {
case 'pending':
return {
label: 'Wartet',
class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
case 'loading':
return {
label: 'Laden...',
class: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
};
case 'complete':
return {
label: 'Fertig',
class: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400',
};
case 'error':
return {
label: 'Fehler',
class: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400',
};
default:
return { label: '', class: '' };
}
});
// Format duration
const formattedDuration = $derived(() => {
if (!result.duration) return null;
const seconds = result.duration / 1000;
return seconds >= 1 ? `${seconds.toFixed(1)}s` : `${result.duration}ms`;
});
</script>
<div
class="rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden transition-all
{isActive ? 'ring-2 ring-primary' : ''}
{result.status === 'error' ? 'border-red-200 dark:border-red-800/50' : 'border-border'}"
>
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/30">
<h3 class="font-medium text-sm truncate">{result.modelName}</h3>
<span class="px-2 py-0.5 rounded-full text-xs font-medium {statusConfig().class}">
{#if result.status === 'loading'}
<span class="inline-flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-current animate-pulse"></span>
{statusConfig().label}
</span>
{:else if result.status === 'complete' && formattedDuration()}
{formattedDuration()}
{:else}
{statusConfig().label}
{/if}
</span>
</div>
<!-- Content -->
<div class="p-4 min-h-[120px] max-h-[400px] overflow-y-auto">
{#if result.status === 'pending'}
<p class="text-muted-foreground text-sm">Wartet auf Verarbeitung...</p>
{:else if result.status === 'loading'}
<div class="flex items-center gap-2 text-muted-foreground text-sm">
<span class="inline-block w-2 h-2 rounded-full bg-primary animate-bounce"></span>
<span
class="inline-block w-2 h-2 rounded-full bg-primary animate-bounce"
style="animation-delay: 0.1s"
></span>
<span
class="inline-block w-2 h-2 rounded-full bg-primary animate-bounce"
style="animation-delay: 0.2s"
></span>
</div>
{:else if result.status === 'error'}
<p class="text-red-500 text-sm">{result.error || 'Ein Fehler ist aufgetreten'}</p>
{:else if result.response}
<div class="prose-chat text-sm">
{@html htmlContent}
</div>
{/if}
</div>
<!-- Footer with metrics (only show when complete) -->
{#if result.status === 'complete' && result.usage}
<div
class="px-4 py-2 border-t border-border bg-muted/20 flex items-center gap-3 text-xs text-muted-foreground"
>
<span>{result.usage.total_tokens} tokens</span>
{#if tokensPerSecond()}
<span class="text-muted-foreground/50">|</span>
<span>{tokensPerSecond()} t/s</span>
{/if}
</div>
{/if}
</div>
<style>
/* Reuse prose-chat styles from MessageBubble */
:global(.prose-chat) {
font-size: 14px;
line-height: 1.6;
}
:global(.prose-chat p) {
margin: 0;
}
:global(.prose-chat p + p) {
margin-top: 0.75em;
}
:global(.prose-chat strong) {
font-weight: 600;
}
:global(.prose-chat em) {
font-style: italic;
}
:global(.prose-chat ul),
:global(.prose-chat ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
:global(.prose-chat li) {
margin: 0.25em 0;
}
:global(.prose-chat code) {
background: rgba(0, 0, 0, 0.08);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: 'SF Mono', 'Fira Code', monospace;
}
:global(.dark .prose-chat code) {
background: rgba(255, 255, 255, 0.1);
}
:global(.prose-chat pre) {
background: rgba(0, 0, 0, 0.05);
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 0.75em 0;
}
:global(.dark .prose-chat pre) {
background: rgba(0, 0, 0, 0.3);
}
:global(.prose-chat pre code) {
background: none;
padding: 0;
font-size: 0.85em;
}
</style>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import type { CompareModelResult } from '@chat/types';
import ModelResponseCard from './ModelResponseCard.svelte';
interface Props {
results: CompareModelResult[];
currentIndex?: number;
}
let { results, currentIndex = 0 }: Props = $props();
</script>
{#if results.length === 0}
<div class="text-center py-12 text-muted-foreground">
<p>Keine Ergebnisse vorhanden.</p>
<p class="text-sm mt-1">Gib einen Prompt ein und starte den Vergleich.</p>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each results as result, index (result.modelId)}
<ModelResponseCard
{result}
isActive={index === currentIndex && result.status === 'loading'}
/>
{/each}
</div>
{/if}

View file

@ -0,0 +1,183 @@
/**
* Compare Store - Manages model comparison state using Svelte 5 runes
*/
import { chatService } from '$lib/services/chat';
import type { AIModel, CompareModelResult, CompareModelStatus, ChatMessage } from '@chat/types';
// State
let results = $state<CompareModelResult[]>([]);
let prompt = $state('');
let temperature = $state(0.7);
let maxTokens = $state(1024);
let isRunning = $state(false);
let currentIndex = $state(0);
let abortController = $state<AbortController | null>(null);
export const compareStore = {
// Getters
get results() {
return results;
},
get prompt() {
return prompt;
},
get temperature() {
return temperature;
},
get maxTokens() {
return maxTokens;
},
get isRunning() {
return isRunning;
},
get currentIndex() {
return currentIndex;
},
get totalModels() {
return results.length;
},
get completedCount() {
return results.filter((r) => r.status === 'complete' || r.status === 'error').length;
},
get currentModelName() {
const current = results[currentIndex];
return current?.modelName || '';
},
get progress() {
if (results.length === 0) return 0;
return (this.completedCount / results.length) * 100;
},
// Setters
setPrompt(value: string) {
prompt = value;
},
setTemperature(value: number) {
temperature = value;
},
setMaxTokens(value: number) {
maxTokens = value;
},
// Actions
async startComparison(models: AIModel[]) {
if (isRunning || !prompt.trim() || models.length === 0) return;
isRunning = true;
currentIndex = 0;
abortController = new AbortController();
// Initialize results with pending status
results = models.map((model) => ({
modelId: model.id,
modelName: model.name,
status: 'pending' as CompareModelStatus,
}));
// Process models sequentially
for (let i = 0; i < models.length; i++) {
if (abortController?.signal.aborted) break;
currentIndex = i;
const model = models[i];
// Update status to loading
results = results.map((r, idx) =>
idx === i ? { ...r, status: 'loading' as CompareModelStatus } : r
);
const startTime = Date.now();
try {
const messages: ChatMessage[] = [{ role: 'user', content: prompt }];
const response = await chatService.createCompletion({
messages,
modelId: model.id,
temperature,
maxTokens,
});
const duration = Date.now() - startTime;
if (abortController?.signal.aborted) break;
if (response) {
results = results.map((r, idx) =>
idx === i
? {
...r,
status: 'complete' as CompareModelStatus,
response: response.content,
duration,
usage: response.usage,
}
: r
);
} else {
results = results.map((r, idx) =>
idx === i
? {
...r,
status: 'error' as CompareModelStatus,
error: 'Keine Antwort erhalten',
duration,
}
: r
);
}
} catch (e) {
const duration = Date.now() - startTime;
if (!abortController?.signal.aborted) {
results = results.map((r, idx) =>
idx === i
? {
...r,
status: 'error' as CompareModelStatus,
error: e instanceof Error ? e.message : 'Unbekannter Fehler',
duration,
}
: r
);
}
}
}
isRunning = false;
abortController = null;
},
cancelComparison() {
if (abortController) {
abortController.abort();
abortController = null;
}
isRunning = false;
// Mark remaining pending/loading items as cancelled
results = results.map((r) =>
r.status === 'pending' || r.status === 'loading'
? { ...r, status: 'error' as CompareModelStatus, error: 'Abgebrochen' }
: r
);
},
reset() {
results = [];
prompt = '';
temperature = 0.7;
maxTokens = 1024;
isRunning = false;
currentIndex = 0;
if (abortController) {
abortController.abort();
abortController = null;
}
},
clearResults() {
results = [];
currentIndex = 0;
},
};

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from 'svelte';
import { chatService } from '$lib/services/chat';
import { compareStore } from '$lib/stores/compare.svelte';
import type { AIModel } from '@chat/types';
import CompareInput from '$lib/components/compare/CompareInput.svelte';
import CompareProgress from '$lib/components/compare/CompareProgress.svelte';
import ModelResponseGrid from '$lib/components/compare/ModelResponseGrid.svelte';
let models = $state<AIModel[]>([]);
let ollamaModels = $state<AIModel[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
models = await chatService.getModels();
// Filter for local ollama models only
ollamaModels = models.filter((m) => m.provider === 'ollama');
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Modelle';
} finally {
isLoading = false;
}
});
function handleCompare() {
if (ollamaModels.length === 0) return;
compareStore.startComparison(ollamaModels);
}
</script>
<svelte:head>
<title>Modell-Vergleich | ManaChat</title>
</svelte:head>
<div class="min-h-screen bg-background">
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Modell-Vergleich</h1>
<p class="text-muted-foreground mt-1">
Vergleiche Antworten verschiedener lokaler Ollama-Modelle nebeneinander.
</p>
</div>
{#if isLoading}
<!-- Loading State -->
<div class="flex items-center justify-center py-12">
<div class="text-center">
<div
class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground mt-4">Lade Modelle...</p>
</div>
</div>
{:else if error}
<!-- Error State -->
<div
class="rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 p-6 text-center"
>
<p class="text-red-600 dark:text-red-400">{error}</p>
<button
onclick={() => location.reload()}
class="mt-4 px-4 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400
hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
>
Erneut versuchen
</button>
</div>
{:else if ollamaModels.length === 0}
<!-- No Models State -->
<div class="rounded-xl border border-border bg-card p-8 text-center">
<div class="text-4xl mb-4">🤖</div>
<h2 class="text-lg font-medium text-foreground mb-2">Keine Ollama-Modelle gefunden</h2>
<p class="text-muted-foreground max-w-md mx-auto">
Es sind keine lokalen Ollama-Modelle verfügbar. Stelle sicher, dass Ollama läuft und
Modelle installiert sind.
</p>
<div class="mt-6 p-4 rounded-lg bg-muted/50 text-left max-w-md mx-auto">
<p class="text-sm text-muted-foreground mb-2">Verfügbare Modelle ({models.length}):</p>
<ul class="text-sm text-foreground">
{#each models as model}
<li class="flex items-center gap-2">
<span class="text-muted-foreground">{model.provider}:</span>
<span>{model.name}</span>
</li>
{/each}
</ul>
</div>
</div>
{:else}
<!-- Main Content -->
<div class="space-y-6">
<!-- Input Section -->
<CompareInput
prompt={compareStore.prompt}
temperature={compareStore.temperature}
maxTokens={compareStore.maxTokens}
isRunning={compareStore.isRunning}
onPromptChange={(v) => compareStore.setPrompt(v)}
onTemperatureChange={(v) => compareStore.setTemperature(v)}
onMaxTokensChange={(v) => compareStore.setMaxTokens(v)}
onCompare={handleCompare}
/>
<!-- Model Count Info -->
<p class="text-sm text-muted-foreground">
{ollamaModels.length} Ollama-Modelle verfügbar:
{ollamaModels.map((m) => m.name).join(', ')}
</p>
<!-- Progress Section (only when running) -->
{#if compareStore.isRunning}
<CompareProgress
currentIndex={compareStore.currentIndex}
totalModels={compareStore.totalModels}
currentModelName={compareStore.currentModelName}
progress={compareStore.progress}
onCancel={() => compareStore.cancelComparison()}
/>
{/if}
<!-- Results Grid -->
<ModelResponseGrid
results={compareStore.results}
currentIndex={compareStore.currentIndex}
/>
<!-- Clear Results Button (when there are results and not running) -->
{#if compareStore.results.length > 0 && !compareStore.isRunning}
<div class="flex justify-center">
<button
onclick={() => compareStore.clearResults()}
class="px-4 py-2 text-sm rounded-lg border border-border
text-muted-foreground hover:text-foreground hover:bg-muted
transition-colors"
>
Ergebnisse löschen
</button>
</div>
{/if}
</div>
{/if}
</div>
</div>

View file

@ -45,7 +45,7 @@ export interface AIModel {
id: string;
name: string;
description?: string;
provider: 'gemini' | 'azure' | 'openai';
provider: 'gemini' | 'azure' | 'openai' | 'ollama' | 'openrouter';
parameters: AIModelParameters;
isActive: boolean;
isDefault: boolean;
@ -125,3 +125,16 @@ export interface Document {
export interface DocumentWithConversation extends Document {
conversationTitle: string;
}
// Model Comparison Types
export type CompareModelStatus = 'pending' | 'loading' | 'complete' | 'error';
export interface CompareModelResult {
modelId: string;
modelName: string;
status: CompareModelStatus;
response?: string;
error?: string;
duration?: number;
usage?: TokenUsage;
}

View file

@ -53,6 +53,7 @@
CreditCard,
Buildings,
User,
Scales,
} from '@manacore/shared-icons';
// Map icon names to Phosphor components
@ -103,6 +104,7 @@
palette: Palette,
creditCard: CreditCard,
building: Buildings,
scale: Scales,
};
// Convert app items to dropdown items (will be computed as derived)