mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
✨ 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:
parent
1d88387c52
commit
d605366460
14 changed files with 741 additions and 569 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
137
services/llm-playground/src/lib/stores/comparison.svelte.ts
Normal file
137
services/llm-playground/src/lib/stores/comparison.svelte.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
28
services/mana-llm/start.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue