mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 23:59:39 +02:00
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:
parent
40718a7554
commit
71b9339310
41 changed files with 527 additions and 530 deletions
9
apps/playground/apps/web/.dockerignore
Normal file
9
apps/playground/apps/web/.dockerignore
Normal 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
7
apps/playground/apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
.svelte-kit/
|
||||
.turbo/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
74
apps/playground/apps/web/Dockerfile
Normal file
74
apps/playground/apps/web/Dockerfile
Normal 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"]
|
||||
33
apps/playground/apps/web/package.json
Normal file
33
apps/playground/apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
103
apps/playground/apps/web/src/app.css
Normal file
103
apps/playground/apps/web/src/app.css
Normal 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
18
apps/playground/apps/web/src/app.d.ts
vendored
Normal 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 {};
|
||||
12
apps/playground/apps/web/src/app.html
Normal file
12
apps/playground/apps/web/src/app.html
Normal 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>
|
||||
18
apps/playground/apps/web/src/hooks.server.ts
Normal file
18
apps/playground/apps/web/src/hooks.server.ts
Normal 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}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
122
apps/playground/apps/web/src/lib/api/llm.ts
Normal file
122
apps/playground/apps/web/src/lib/api/llm.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
147
apps/playground/apps/web/src/lib/stores/auth.svelte.ts
Normal file
147
apps/playground/apps/web/src/lib/stores/auth.svelte.ts
Normal 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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
205
apps/playground/apps/web/src/lib/stores/chat.svelte.ts
Normal file
205
apps/playground/apps/web/src/lib/stores/chat.svelte.ts
Normal 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();
|
||||
137
apps/playground/apps/web/src/lib/stores/comparison.svelte.ts
Normal file
137
apps/playground/apps/web/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();
|
||||
196
apps/playground/apps/web/src/lib/stores/models.svelte.ts
Normal file
196
apps/playground/apps/web/src/lib/stores/models.svelte.ts
Normal 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();
|
||||
84
apps/playground/apps/web/src/lib/stores/settings.svelte.ts
Normal file
84
apps/playground/apps/web/src/lib/stores/settings.svelte.ts
Normal 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();
|
||||
125
apps/playground/apps/web/src/lib/types/index.ts
Normal file
125
apps/playground/apps/web/src/lib/types/index.ts
Normal 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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
21
apps/playground/apps/web/src/routes/(protected)/+page.svelte
Normal file
21
apps/playground/apps/web/src/routes/(protected)/+page.svelte
Normal 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>
|
||||
13
apps/playground/apps/web/src/routes/+layout.svelte
Normal file
13
apps/playground/apps/web/src/routes/+layout.svelte
Normal 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>
|
||||
10
apps/playground/apps/web/src/routes/health/+server.ts
Normal file
10
apps/playground/apps/web/src/routes/health/+server.ts
Normal 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(),
|
||||
});
|
||||
};
|
||||
BIN
apps/playground/apps/web/static/favicon.png
Normal file
BIN
apps/playground/apps/web/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 512 B |
12
apps/playground/apps/web/svelte.config.js
Normal file
12
apps/playground/apps/web/svelte.config.js
Normal 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;
|
||||
14
apps/playground/apps/web/tsconfig.json
Normal file
14
apps/playground/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/playground/apps/web/vite.config.ts
Normal file
7
apps/playground/apps/web/vite.config.ts
Normal 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()],
|
||||
});
|
||||
7
apps/playground/package.json
Normal file
7
apps/playground/package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "playground",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue