mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(llm-playground): add SvelteKit LLM playground UI
- Chat interface with message history - Model selector for available LLM models - Parameter panel (temperature, max tokens, etc.) - System prompt editor - Svelte 5 runes-based stores Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3edbd0cb26
commit
f880ef2b7f
24 changed files with 1245 additions and 0 deletions
7
services/llm-playground/.gitignore
vendored
Normal file
7
services/llm-playground/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
.svelte-kit/
|
||||
.turbo/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
26
services/llm-playground/package.json
Normal file
26
services/llm-playground/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@mana-llm/playground",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "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": {
|
||||
"marked": "^17.0.0"
|
||||
}
|
||||
}
|
||||
103
services/llm-playground/src/app.css
Normal file
103
services/llm-playground/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);
|
||||
}
|
||||
13
services/llm-playground/src/app.d.ts
vendored
Normal file
13
services/llm-playground/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
services/llm-playground/src/app.html
Normal file
12
services/llm-playground/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>
|
||||
112
services/llm-playground/src/lib/api/llm.ts
Normal file
112
services/llm-playground/src/lib/api/llm.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type {
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
HealthResponse,
|
||||
ModelsResponse,
|
||||
StreamChunk,
|
||||
} from '$lib/types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const API_BASE = env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025';
|
||||
|
||||
export async function getHealth(): Promise<HealthResponse> {
|
||||
const response = await fetch(`${API_BASE}/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(`${API_BASE}/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(`${API_BASE}/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(`${API_BASE}/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,77 @@
|
|||
<script lang="ts">
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
|
||||
let input = $state('');
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
function handleSubmit() {
|
||||
if (!input.trim() || chatStore.isStreaming) return;
|
||||
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,62 @@
|
|||
<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,43 @@
|
|||
<script lang="ts">
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import MessageBubble from './MessageBubble.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)}
|
||||
<MessageBubble {message} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<script lang="ts">
|
||||
import { getHealth } from '$lib/api/llm';
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<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 { chatStore } from '$lib/stores/chat.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>
|
||||
|
||||
<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,55 @@
|
|||
<script lang="ts">
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
modelsStore.loadModels();
|
||||
});
|
||||
</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>
|
||||
<p class="mt-1.5 text-xs" style="color: var(--color-text-muted);">
|
||||
{modelsStore.models.length} models available
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<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>
|
||||
141
services/llm-playground/src/lib/stores/chat.svelte.ts
Normal file
141
services/llm-playground/src/lib/stores/chat.svelte.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import type { ChatMessage, Message } from '$lib/types';
|
||||
import { streamCompletion } from '$lib/api/llm';
|
||||
import { settingsStore } from './settings.svelte';
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function createChatStore() {
|
||||
let messages = $state<ChatMessage[]>([]);
|
||||
let isStreaming = $state(false);
|
||||
let abortController = $state<AbortController | null>(null);
|
||||
|
||||
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 === 'user' || (msg.role === 'assistant' && !msg.isStreaming)) {
|
||||
apiMessages.push({
|
||||
role: msg.role,
|
||||
content: msg.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].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();
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
} finally {
|
||||
isStreaming = false;
|
||||
abortController = null;
|
||||
}
|
||||
},
|
||||
|
||||
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) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
model: m.model,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const chatStore = createChatStore();
|
||||
88
services/llm-playground/src/lib/stores/models.svelte.ts
Normal file
88
services/llm-playground/src/lib/stores/models.svelte.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { Model, Provider } from '$lib/types';
|
||||
import { getModels } from '$lib/api/llm';
|
||||
|
||||
interface GroupedModels {
|
||||
provider: Provider;
|
||||
label: string;
|
||||
models: Model[];
|
||||
}
|
||||
|
||||
function createModelsStore() {
|
||||
let models = $state<Model[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
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
services/llm-playground/src/lib/stores/settings.svelte.ts
Normal file
84
services/llm-playground/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/llama3.2:3b',
|
||||
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();
|
||||
88
services/llm-playground/src/lib/types/index.ts
Normal file
88
services/llm-playground/src/lib/types/index.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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';
|
||||
13
services/llm-playground/src/routes/+layout.svelte
Normal file
13
services/llm-playground/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>
|
||||
21
services/llm-playground/src/routes/+page.svelte
Normal file
21
services/llm-playground/src/routes/+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>
|
||||
BIN
services/llm-playground/static/favicon.png
Normal file
BIN
services/llm-playground/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 512 B |
12
services/llm-playground/svelte.config.js
Normal file
12
services/llm-playground/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
services/llm-playground/tsconfig.json
Normal file
14
services/llm-playground/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
services/llm-playground/vite.config.ts
Normal file
7
services/llm-playground/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()],
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue