mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
✨ 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:
parent
14c83cb4bd
commit
ca00672016
8 changed files with 716 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
183
apps/chat/apps/web/src/lib/stores/compare.svelte.ts
Normal file
183
apps/chat/apps/web/src/lib/stores/compare.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
146
apps/chat/apps/web/src/routes/(protected)/compare/+page.svelte
Normal file
146
apps/chat/apps/web/src/routes/(protected)/compare/+page.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue