proxy,ui-svelte: replace old UI with svelte+playground
Replace the legacy React UI with the new Svelte-based one. Introduce a Playground in the UI to quickly test out text, image, text to speech and speech to text models behind llama-swap.
Key Changes
New Svelte UI (ui-svelte/)
- Multi-tab Playground with Chat, Image Generation, Audio Transcription, and Speech interfaces
- Chat: message editing/regeneration, markdown rendering with LaTeX math support, image attachments, code syntax highlighting
- Image: size selector, download/fullscreen viewing
- Audio: transcription with peer support
- Speech: voice caching with manual refresh, download button
- Responsive mobile layout with collapsible navigation
- XSS fixes and accessibility improvements
Proxy Improvements
- Add gzip/brotli compression for UI static assets (proxy/ui_compress.go)
- Add GET /v1/audio/voices?model={model} endpoint for voice listing
- Add peer support for /v1/audio/transcriptions
This commit is contained in:
Generated
+2023
-24
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -7,19 +7,36 @@
|
||||
"start": "vite",
|
||||
"build": "vite build --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^25.1.0",
|
||||
"svelte": "^5.19.0",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-compression2": "^2.4.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-svelte": "^0.563.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
import LogViewer from "./routes/LogViewer.svelte";
|
||||
import Models from "./routes/Models.svelte";
|
||||
import Activity from "./routes/Activity.svelte";
|
||||
import Playground from "./routes/Playground.svelte";
|
||||
import { enableAPIEvents } from "./stores/api";
|
||||
import { initScreenWidth, isDarkMode, appTitle, connectionState } from "./stores/theme";
|
||||
|
||||
const routes = {
|
||||
"/": Models,
|
||||
"/": Playground,
|
||||
"/models": Models,
|
||||
"/logs": LogViewer,
|
||||
"/activity": Activity,
|
||||
"*": Models,
|
||||
"*": Playground,
|
||||
};
|
||||
|
||||
// Sync theme to document attribute
|
||||
|
||||
@@ -43,19 +43,27 @@
|
||||
</h1>
|
||||
{/if}
|
||||
|
||||
<menu class="flex items-center gap-4">
|
||||
<menu class="flex items-center gap-4 overflow-x-auto">
|
||||
<a
|
||||
href="/"
|
||||
use:link
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1"
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||
class:font-semibold={isActive("/", $location)}
|
||||
>
|
||||
Playground
|
||||
</a>
|
||||
<a
|
||||
href="/models"
|
||||
use:link
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||
class:font-semibold={isActive("/models", $location)}
|
||||
>
|
||||
Models
|
||||
</a>
|
||||
<a
|
||||
href="/activity"
|
||||
use:link
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1"
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||
class:font-semibold={isActive("/activity", $location)}
|
||||
>
|
||||
Activity
|
||||
@@ -63,7 +71,7 @@
|
||||
<a
|
||||
href="/logs"
|
||||
use:link
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1"
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||
class:font-semibold={isActive("/logs", $location)}
|
||||
>
|
||||
Logs
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { models } from "../../stores/api";
|
||||
import { persistentStore } from "../../stores/persistent";
|
||||
import { transcribeAudio } from "../../lib/audioApi";
|
||||
import ModelSelector from "./ModelSelector.svelte";
|
||||
|
||||
const selectedModelStore = persistentStore<string>("playground-audio-model", "");
|
||||
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let isTranscribing = $state(false);
|
||||
let transcriptionResult = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let abortController = $state<AbortController | null>(null);
|
||||
let isDragging = $state(false);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
const ACCEPTED_FORMATS = ['.mp3', '.wav'];
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
|
||||
|
||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||
|
||||
let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing);
|
||||
|
||||
function validateFile(file: File): { valid: boolean; error?: string } {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (!ACCEPTED_FORMATS.includes(ext)) {
|
||||
return { valid: false, error: 'Invalid file type. Accepted: MP3, WAV' };
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { valid: false, error: 'File too large. Maximum: 25MB' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
const validation = validateFile(file);
|
||||
if (validation.valid) {
|
||||
selectedFile = file;
|
||||
error = null;
|
||||
transcriptionResult = null;
|
||||
} else {
|
||||
error = validation.error || "Invalid file";
|
||||
selectedFile = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (file) {
|
||||
const validation = validateFile(file);
|
||||
if (validation.valid) {
|
||||
selectedFile = file;
|
||||
error = null;
|
||||
transcriptionResult = null;
|
||||
} else {
|
||||
error = validation.error || "Invalid file";
|
||||
selectedFile = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribe() {
|
||||
if (!selectedFile || !$selectedModelStore || isTranscribing) return;
|
||||
|
||||
isTranscribing = true;
|
||||
error = null;
|
||||
transcriptionResult = null;
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await transcribeAudio(
|
||||
$selectedModelStore,
|
||||
selectedFile,
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
transcriptionResult = response.text;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// User cancelled
|
||||
} else {
|
||||
error = err instanceof Error ? err.message : "An error occurred";
|
||||
}
|
||||
} finally {
|
||||
isTranscribing = false;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTranscription() {
|
||||
abortController?.abort();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
selectedFile = null;
|
||||
transcriptionResult = null;
|
||||
error = null;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
if (transcriptionResult) {
|
||||
navigator.clipboard.writeText(transcriptionResult);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Model selector -->
|
||||
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an audio model..." disabled={isTranscribing} />
|
||||
<button class="btn" onclick={clearAll} disabled={!selectedFile && !transcriptionResult && !error}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for no models configured -->
|
||||
{#if !hasModels}
|
||||
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||
<p>No models configured. Add models to your configuration to transcribe audio.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- File upload / Result display area -->
|
||||
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded">
|
||||
{#if isTranscribing}
|
||||
<div class="text-center text-txtsecondary">
|
||||
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||
<p>Transcribing audio...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center text-red-500 p-4">
|
||||
<p class="font-medium">Error</p>
|
||||
<p class="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
{:else if transcriptionResult}
|
||||
<div class="w-full h-full flex flex-col p-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-medium">Transcription Result</h3>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
onclick={copyToClipboard}
|
||||
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
{#if copied}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-3 rounded border border-gray-200 dark:border-white/10 bg-background whitespace-pre-wrap">
|
||||
{transcriptionResult}
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedFile}
|
||||
<div class="text-center text-txtsecondary p-4">
|
||||
<p class="font-medium mb-2">File Selected</p>
|
||||
<p class="text-sm">{selectedFile.name}</p>
|
||||
<p class="text-xs mt-1">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Audio file drop zone"
|
||||
class="w-full h-full flex items-center justify-center text-center text-txtsecondary p-8 {isDragging ? 'bg-primary/10' : ''}"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div>
|
||||
<p class="mb-2">Drag and drop an audio file here</p>
|
||||
<p class="text-sm">or use the Browse button below</p>
|
||||
<p class="text-xs mt-4">Accepted formats: MP3, WAV (max 25MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File input and transcribe button -->
|
||||
<div class="shrink-0 flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp3,.wav"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
bind:this={fileInput}
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={isTranscribing}
|
||||
>
|
||||
Browse Files
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
{#if isTranscribing}
|
||||
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelTranscription}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn bg-primary text-btn-primary-text hover:opacity-90"
|
||||
onclick={transcribe}
|
||||
disabled={!canTranscribe}
|
||||
>
|
||||
Transcribe
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,424 @@
|
||||
<script lang="ts">
|
||||
import { models } from "../../stores/api";
|
||||
import { persistentStore } from "../../stores/persistent";
|
||||
import { streamChatCompletion } from "../../lib/chatApi";
|
||||
import type { ChatMessage, ContentPart } from "../../lib/types";
|
||||
import ChatMessageComponent from "./ChatMessage.svelte";
|
||||
import ModelSelector from "./ModelSelector.svelte";
|
||||
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||
|
||||
const selectedModelStore = persistentStore<string>("playground-selected-model", "");
|
||||
const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
|
||||
const temperatureStore = persistentStore<number>("playground-temperature", 0.7);
|
||||
|
||||
let messages = $state<ChatMessage[]>([]);
|
||||
let userInput = $state("");
|
||||
let isStreaming = $state(false);
|
||||
let isReasoning = $state(false);
|
||||
let reasoningStartTime = $state<number>(0);
|
||||
let abortController = $state<AbortController | null>(null);
|
||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||
let showSettings = $state(false);
|
||||
let attachedImages = $state<string[]>([]);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let imageError = $state<string | null>(null);
|
||||
|
||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||
|
||||
// Auto-scroll when messages change
|
||||
$effect(() => {
|
||||
if (messages.length > 0 && messagesContainer) {
|
||||
messagesContainer.scrollTo({
|
||||
top: messagesContainer.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function sendMessage() {
|
||||
const trimmedInput = userInput.trim();
|
||||
if ((!trimmedInput && attachedImages.length === 0) || !$selectedModelStore || isStreaming) return;
|
||||
|
||||
// Build message content (multimodal if images attached)
|
||||
let content: string | ContentPart[];
|
||||
if (attachedImages.length > 0) {
|
||||
const parts: ContentPart[] = [];
|
||||
if (trimmedInput) {
|
||||
parts.push({ type: "text", text: trimmedInput });
|
||||
}
|
||||
for (const url of attachedImages) {
|
||||
parts.push({ type: "image_url", image_url: { url } });
|
||||
}
|
||||
content = parts;
|
||||
} else {
|
||||
content = trimmedInput;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
messages = [...messages, { role: "user", content }];
|
||||
userInput = "";
|
||||
attachedImages = [];
|
||||
imageError = null;
|
||||
|
||||
// Generate response from the new user message
|
||||
await regenerateFromIndex(messages.length - 1);
|
||||
}
|
||||
|
||||
function cancelStreaming() {
|
||||
abortController?.abort();
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
if (isStreaming) {
|
||||
cancelStreaming();
|
||||
}
|
||||
messages = [];
|
||||
isReasoning = false;
|
||||
reasoningStartTime = 0;
|
||||
}
|
||||
|
||||
async function regenerateFromIndex(idx: number) {
|
||||
// Remove all messages after the edited user message
|
||||
messages = messages.slice(0, idx + 1);
|
||||
|
||||
// Add empty assistant message for the new response
|
||||
messages = [...messages, { role: "assistant", content: "" }];
|
||||
|
||||
isStreaming = true;
|
||||
isReasoning = false;
|
||||
reasoningStartTime = 0;
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Build messages array with optional system prompt
|
||||
const apiMessages: ChatMessage[] = [];
|
||||
if ($systemPromptStore.trim()) {
|
||||
apiMessages.push({ role: "system", content: $systemPromptStore.trim() });
|
||||
}
|
||||
apiMessages.push(...messages.slice(0, -1)); // Add all messages except the empty assistant one
|
||||
|
||||
const stream = streamChatCompletion(
|
||||
$selectedModelStore,
|
||||
apiMessages,
|
||||
abortController.signal,
|
||||
{ temperature: $temperatureStore }
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.done) break;
|
||||
|
||||
// Handle reasoning content
|
||||
if (chunk.reasoning_content) {
|
||||
// Start timing on first reasoning content
|
||||
if (!isReasoning) {
|
||||
isReasoning = true;
|
||||
reasoningStartTime = Date.now();
|
||||
}
|
||||
|
||||
// Update the last message with reasoning content
|
||||
messages = messages.map((msg, i) =>
|
||||
i === messages.length - 1
|
||||
? { ...msg, reasoning_content: (msg.reasoning_content || "") + chunk.reasoning_content }
|
||||
: msg
|
||||
);
|
||||
}
|
||||
|
||||
// Handle regular content - end reasoning phase when we get content
|
||||
if (chunk.content) {
|
||||
if (isReasoning) {
|
||||
// Calculate reasoning time
|
||||
const reasoningTimeMs = Date.now() - reasoningStartTime;
|
||||
isReasoning = false;
|
||||
|
||||
// Update message with reasoning time
|
||||
messages = messages.map((msg, i) =>
|
||||
i === messages.length - 1
|
||||
? { ...msg, reasoningTimeMs }
|
||||
: msg
|
||||
);
|
||||
}
|
||||
|
||||
// Update the last message (assistant) with new content
|
||||
messages = messages.map((msg, i) =>
|
||||
i === messages.length - 1
|
||||
? { ...msg, content: msg.content + chunk.content }
|
||||
: msg
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// User cancelled, keep partial response
|
||||
// If we were still reasoning, record the time
|
||||
if (isReasoning && reasoningStartTime > 0) {
|
||||
const reasoningTimeMs = Date.now() - reasoningStartTime;
|
||||
messages = messages.map((msg, i) =>
|
||||
i === messages.length - 1
|
||||
? { ...msg, reasoningTimeMs }
|
||||
: msg
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Show error in the assistant message
|
||||
const errorMessage = error instanceof Error ? error.message : "An error occurred";
|
||||
messages = messages.map((msg, i) =>
|
||||
i === messages.length - 1
|
||||
? { ...msg, content: msg.content + `\n\n**Error:** ${errorMessage}` }
|
||||
: msg
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isStreaming = false;
|
||||
isReasoning = false;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function editMessage(idx: number, newContent: string) {
|
||||
if (isStreaming || !$selectedModelStore) return;
|
||||
|
||||
// Update the user message at the specified index
|
||||
messages = messages.map((msg, i) =>
|
||||
i === idx ? { ...msg, content: newContent } : msg
|
||||
);
|
||||
|
||||
// Trigger a new chat request with the updated messages
|
||||
await regenerateFromIndex(idx);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_FORMATS = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
const MAX_IMAGES_PER_MESSAGE = 5;
|
||||
|
||||
function validateImageFile(file: File): string | null {
|
||||
if (!ACCEPTED_IMAGE_FORMATS.includes(file.type)) {
|
||||
return `Invalid file type: ${file.type}. Accepted formats: JPG, PNG, GIF, WEBP`;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum size: 20MB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function processImageFiles(files: File[]): Promise<void> {
|
||||
imageError = null;
|
||||
|
||||
if (attachedImages.length + files.length > MAX_IMAGES_PER_MESSAGE) {
|
||||
imageError = `Maximum ${MAX_IMAGES_PER_MESSAGE} images per message`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const error = validateImageFile(file);
|
||||
if (error) {
|
||||
imageError = error;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrls = await Promise.all(files.map(fileToDataUrl));
|
||||
attachedImages = [...attachedImages, ...dataUrls];
|
||||
} catch (error) {
|
||||
imageError = error instanceof Error ? error.message : "Failed to process images";
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
processImageFiles(Array.from(input.files));
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function removeImage(idx: number) {
|
||||
attachedImages = attachedImages.filter((_, i) => i !== idx);
|
||||
imageError = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Model selector and controls -->
|
||||
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} />
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn"
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
title="Settings"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.295a1 1 0 0 1 .804.98v1.36a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.587 1.416l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.953 6.953 0 0 1-1.416.587l-.295 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.957 6.957 0 0 1-1.416-.587l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.957 6.957 0 0 1-.587-1.416l-1.473-.295A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962A1 1 0 0 1 5.38 3.03l1.25.834a6.957 6.957 0 0 1 1.416-.587l.294-1.473ZM13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel -->
|
||||
{#if showSettings}
|
||||
<div class="shrink-0 mb-4 p-4 bg-surface border border-gray-200 dark:border-white/10 rounded">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" for="system-prompt">System Prompt</label>
|
||||
<textarea
|
||||
id="system-prompt"
|
||||
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows="3"
|
||||
bind:value={$systemPromptStore}
|
||||
disabled={isStreaming}
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" for="temperature">
|
||||
Temperature: {$temperatureStore.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
id="temperature"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.05"
|
||||
class="w-full"
|
||||
bind:value={$temperatureStore}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-txtsecondary mt-1">
|
||||
<span>Precise (0)</span>
|
||||
<span>Creative (2)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state for no models configured -->
|
||||
{#if !hasModels}
|
||||
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||
<p>No models configured. Add models to your configuration to start chatting.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Messages area -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto mb-4 px-2"
|
||||
bind:this={messagesContainer}
|
||||
>
|
||||
{#if messages.length === 0}
|
||||
<div class="h-full flex items-center justify-center text-txtsecondary">
|
||||
<p>Start a conversation by typing a message below.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message, idx (idx)}
|
||||
<ChatMessageComponent
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
reasoning_content={message.reasoning_content}
|
||||
reasoningTimeMs={message.reasoningTimeMs}
|
||||
isStreaming={isStreaming && idx === messages.length - 1 && message.role === "assistant"}
|
||||
isReasoning={isReasoning && idx === messages.length - 1 && message.role === "assistant"}
|
||||
onEdit={message.role === "user" ? (newContent) => editMessage(idx, newContent) : undefined}
|
||||
onRegenerate={message.role === "assistant" && idx > 0 && messages[idx - 1].role === "user"
|
||||
? () => regenerateFromIndex(idx - 1)
|
||||
: undefined}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="shrink-0">
|
||||
<!-- Image preview strip -->
|
||||
{#if attachedImages.length > 0}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each attachedImages as imageUrl, idx (idx)}
|
||||
<div class="relative group">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Attached image {idx + 1}"
|
||||
class="w-20 h-20 object-cover rounded border border-gray-200 dark:border-white/10"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => removeImage(idx)}
|
||||
title="Remove image"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error message -->
|
||||
{#if imageError}
|
||||
<div class="mb-2 p-2 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded text-sm">
|
||||
{imageError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||
multiple
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={handleImageSelect}
|
||||
/>
|
||||
|
||||
<ExpandableTextarea
|
||||
bind:value={userInput}
|
||||
placeholder="Type a message..."
|
||||
rows={3}
|
||||
onkeydown={handleKeyDown}
|
||||
disabled={isStreaming || !$selectedModelStore}
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if isStreaming}
|
||||
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelStreaming}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn"
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={isStreaming || !$selectedModelStore}
|
||||
title="Attach image"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn bg-primary text-btn-primary-text hover:opacity-90"
|
||||
onclick={sendMessage}
|
||||
disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,388 @@
|
||||
<script lang="ts">
|
||||
import { renderMarkdown, escapeHtml } from "../../lib/markdown";
|
||||
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte";
|
||||
import { getTextContent, getImageUrls } from "../../lib/types";
|
||||
import type { ContentPart } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string | ContentPart[];
|
||||
reasoning_content?: string;
|
||||
reasoningTimeMs?: number;
|
||||
isStreaming?: boolean;
|
||||
isReasoning?: boolean;
|
||||
onEdit?: (newContent: string) => void;
|
||||
onRegenerate?: () => void;
|
||||
}
|
||||
|
||||
let { role, content, reasoning_content = "", reasoningTimeMs = 0, isStreaming = false, isReasoning = false, onEdit, onRegenerate }: Props = $props();
|
||||
|
||||
let textContent = $derived(getTextContent(content));
|
||||
let imageUrls = $derived(getImageUrls(content));
|
||||
let hasImages = $derived(imageUrls.length > 0);
|
||||
let canEdit = $derived(onEdit !== undefined && !hasImages);
|
||||
|
||||
let renderedContent = $derived(
|
||||
role === "assistant" && !isStreaming
|
||||
? renderMarkdown(textContent)
|
||||
: escapeHtml(textContent).replace(/\n/g, '<br>')
|
||||
);
|
||||
let copied = $state(false);
|
||||
let showRaw = $state(false);
|
||||
let isEditing = $state(false);
|
||||
let editContent = $state("");
|
||||
let showReasoning = $state(false);
|
||||
let modalImageUrl = $state<string | null>(null);
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms.toFixed(0)}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(textContent);
|
||||
} else {
|
||||
// Fallback for non-secure contexts (HTTP)
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = textContent;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editContent = textContent;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
editContent = "";
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (onEdit && editContent.trim() !== textContent) {
|
||||
onEdit(editContent.trim());
|
||||
}
|
||||
isEditing = false;
|
||||
editContent = "";
|
||||
}
|
||||
|
||||
function openModal(imageUrl: string) {
|
||||
modalImageUrl = imageUrl;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeModal(event?: MouseEvent) {
|
||||
// Only close if clicking the background, not the image
|
||||
if (event && event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
modalImageUrl = null;
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
function handleModalKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
saveEdit();
|
||||
} else if (event.key === "Escape") {
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
|
||||
<div
|
||||
class="relative group max-w-[85%] rounded-lg px-4 py-2 {role === 'user'
|
||||
? 'bg-primary text-btn-primary-text'
|
||||
: 'bg-surface border border-gray-200 dark:border-white/10'}"
|
||||
>
|
||||
{#if role === "assistant"}
|
||||
{#if reasoning_content || isReasoning}
|
||||
<div class="mb-3 border border-gray-200 dark:border-white/10 rounded overflow-hidden">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-white/5 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors text-sm"
|
||||
onclick={() => showReasoning = !showReasoning}
|
||||
>
|
||||
{#if showReasoning}
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
{/if}
|
||||
<Brain class="w-4 h-4" />
|
||||
<span class="font-medium">Reasoning</span>
|
||||
<span class="text-txtsecondary ml-2">
|
||||
({reasoning_content.length} chars{#if !isReasoning && reasoningTimeMs > 0}, {formatDuration(reasoningTimeMs)}{/if})
|
||||
</span>
|
||||
{#if isReasoning}
|
||||
<span class="ml-auto flex items-center gap-1 text-txtsecondary">
|
||||
<span class="w-1.5 h-1.5 bg-primary rounded-full animate-pulse"></span>
|
||||
reasoning...
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if showReasoning}
|
||||
<div class="px-3 py-2 bg-gray-50/50 dark:bg-white/[0.02] text-sm text-txtsecondary whitespace-pre-wrap font-mono">
|
||||
{reasoning_content}{#if isReasoning}<span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5"></span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasImages}
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
{#each imageUrls as imageUrl, idx (idx)}
|
||||
<button
|
||||
onclick={() => openModal(imageUrl)}
|
||||
class="cursor-pointer rounded border border-gray-200 dark:border-white/10 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Image {idx + 1}"
|
||||
class="max-h-64 rounded"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showRaw}
|
||||
<div class="whitespace-pre-wrap font-mono text-sm">{textContent}</div>
|
||||
{:else}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html renderedContent}
|
||||
{#if isStreaming && !isReasoning}
|
||||
<span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isStreaming}
|
||||
<div class="flex gap-1 mt-2 pt-1 border-t border-gray-200 dark:border-white/10">
|
||||
{#if onRegenerate}
|
||||
<button
|
||||
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
|
||||
onclick={onRegenerate}
|
||||
title="Regenerate response"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
|
||||
onclick={copyToClipboard}
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 {showRaw ? 'text-primary' : 'text-txtsecondary'}"
|
||||
onclick={() => showRaw = !showRaw}
|
||||
title={showRaw ? "Show rendered" : "Show raw"}
|
||||
>
|
||||
<Code class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if isEditing}
|
||||
<div class="flex flex-col gap-2 min-w-[300px]">
|
||||
<textarea
|
||||
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface text-txtmain focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
rows="3"
|
||||
bind:value={editContent}
|
||||
onkeydown={handleKeyDown}
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-white/20"
|
||||
onclick={cancelEdit}
|
||||
title="Cancel"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-white/20"
|
||||
onclick={saveEdit}
|
||||
title="Save"
|
||||
>
|
||||
<Save class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasImages}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each imageUrls as imageUrl, idx (idx)}
|
||||
<button
|
||||
onclick={() => openModal(imageUrl)}
|
||||
class="cursor-pointer rounded border border-white/20 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Image {idx + 1}"
|
||||
class="max-w-[200px] rounded"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="whitespace-pre-wrap pr-8">{textContent}</div>
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-white/20 hover:bg-white/30 shadow-sm"
|
||||
onclick={startEdit}
|
||||
title="Edit message"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full-size image modal -->
|
||||
{#if modalImageUrl}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onclick={(e) => closeModal(e)}
|
||||
onkeydown={handleModalKeyDown}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
onclick={() => closeModal()}
|
||||
title="Close"
|
||||
>
|
||||
<X class="w-6 h-6" />
|
||||
</button>
|
||||
<img
|
||||
src={modalImageUrl}
|
||||
alt=""
|
||||
class="max-w-full max-h-full rounded pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.prose :global(pre) {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.prose :global(code) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prose :global(code:not(pre code)) {
|
||||
background-color: var(--color-surface);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
|
||||
}
|
||||
|
||||
.prose :global(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.prose :global(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose :global(ul),
|
||||
.prose :global(ol) {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.prose :global(li) {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.prose :global(h1),
|
||||
.prose :global(h2),
|
||||
.prose :global(h3),
|
||||
.prose :global(h4) {
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose :global(h1:first-child),
|
||||
.prose :global(h2:first-child),
|
||||
.prose :global(h3:first-child),
|
||||
.prose :global(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose :global(blockquote) {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding-left: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose :global(a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose :global(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.prose :global(th),
|
||||
.prose :global(td) {
|
||||
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose :global(th) {
|
||||
background-color: var(--color-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Highlight.js theme overrides for dark mode */
|
||||
:global(.dark) .prose :global(.hljs) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { Maximize2, X } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
onkeydown?: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
placeholder = "",
|
||||
rows = 3,
|
||||
disabled = false,
|
||||
onkeydown,
|
||||
}: Props = $props();
|
||||
|
||||
let isExpanded = $state(false);
|
||||
let expandedValue = $state("");
|
||||
let expandedTextarea: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
function openExpanded() {
|
||||
expandedValue = value;
|
||||
isExpanded = true;
|
||||
}
|
||||
|
||||
function closeExpanded() {
|
||||
isExpanded = false;
|
||||
}
|
||||
|
||||
function saveExpanded() {
|
||||
value = expandedValue;
|
||||
isExpanded = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeExpanded();
|
||||
}
|
||||
}
|
||||
|
||||
// Focus the textarea when expanded view opens
|
||||
$effect(() => {
|
||||
if (isExpanded && expandedTextarea) {
|
||||
expandedTextarea.focus();
|
||||
expandedTextarea.setSelectionRange(expandedValue.length, expandedValue.length);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 relative group flex items-stretch min-h-0">
|
||||
<textarea
|
||||
class="w-full px-3 py-2 pr-10 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary resize-none"
|
||||
{placeholder}
|
||||
{rows}
|
||||
bind:value
|
||||
{onkeydown}
|
||||
{disabled}
|
||||
></textarea>
|
||||
<button
|
||||
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-surface/90 hover:bg-surface border border-gray-200 dark:border-white/10 shadow-sm"
|
||||
onclick={openExpanded}
|
||||
title="Expand to edit"
|
||||
type="button"
|
||||
{disabled}
|
||||
>
|
||||
<Maximize2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="w-full max-w-4xl h-[80vh] flex flex-col bg-surface rounded-lg shadow-xl border border-gray-200 dark:border-white/10">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<h3 class="font-medium">Edit Text</h3>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
onclick={closeExpanded}
|
||||
title="Close"
|
||||
type="button"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Textarea -->
|
||||
<div class="flex-1 p-4">
|
||||
<textarea
|
||||
bind:this={expandedTextarea}
|
||||
class="w-full h-full px-4 py-3 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
placeholder={placeholder}
|
||||
bind:value={expandedValue}
|
||||
onkeydown={handleKeyDown}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
class="btn"
|
||||
onclick={closeExpanded}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn bg-primary text-btn-primary-text hover:opacity-90"
|
||||
onclick={saveExpanded}
|
||||
type="button"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { models } from "../../stores/api";
|
||||
import { persistentStore } from "../../stores/persistent";
|
||||
import { generateImage } from "../../lib/imageApi";
|
||||
import ModelSelector from "./ModelSelector.svelte";
|
||||
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||
|
||||
const selectedModelStore = persistentStore<string>("playground-image-model", "");
|
||||
const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024");
|
||||
|
||||
let prompt = $state("");
|
||||
let isGenerating = $state(false);
|
||||
let generatedImage = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let abortController = $state<AbortController | null>(null);
|
||||
let showFullscreen = $state(false);
|
||||
|
||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||
|
||||
async function generate() {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
|
||||
|
||||
isGenerating = true;
|
||||
error = null;
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await generateImage(
|
||||
$selectedModelStore,
|
||||
trimmedPrompt,
|
||||
$selectedSizeStore,
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
const imageData = response.data[0];
|
||||
if (imageData.b64_json) {
|
||||
generatedImage = `data:image/png;base64,${imageData.b64_json}`;
|
||||
} else if (imageData.url) {
|
||||
generatedImage = imageData.url;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// User cancelled
|
||||
} else {
|
||||
error = err instanceof Error ? err.message : "An error occurred";
|
||||
}
|
||||
} finally {
|
||||
isGenerating = false;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelGeneration() {
|
||||
abortController?.abort();
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
generatedImage = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function downloadImage() {
|
||||
if (!generatedImage) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = generatedImage;
|
||||
link.download = `generated-image-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function openFullscreen() {
|
||||
showFullscreen = true;
|
||||
}
|
||||
|
||||
function closeFullscreen(event?: MouseEvent) {
|
||||
// Only close if clicking the background, not the image
|
||||
if (event && event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
showFullscreen = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
generate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Model selector -->
|
||||
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} />
|
||||
<select
|
||||
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value={$selectedSizeStore}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<optgroup label="Square">
|
||||
<option value="512x512">512x512</option>
|
||||
<option value="1024x1024">1024x1024</option>
|
||||
</optgroup>
|
||||
<optgroup label="Landscape">
|
||||
<option value="1024x768">1024x768 (4:3)</option>
|
||||
<option value="1280x720">1280x720 (16:9)</option>
|
||||
<option value="1792x1024">1792x1024 (SDXL)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Portrait">
|
||||
<option value="768x1024">768x1024 (3:4)</option>
|
||||
<option value="720x1280">720x1280 (9:16)</option>
|
||||
<option value="1024x1792">1024x1792 (SDXL)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button class="btn" onclick={clearImage} disabled={!generatedImage && !error}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for no models configured -->
|
||||
{#if !hasModels}
|
||||
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||
<p>No models configured. Add models to your configuration to generate images.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Image display area -->
|
||||
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded">
|
||||
{#if isGenerating}
|
||||
<div class="text-center text-txtsecondary">
|
||||
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||
<p>Generating image...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center text-red-500 p-4">
|
||||
<p class="font-medium">Error</p>
|
||||
<p class="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
{:else if generatedImage}
|
||||
<div class="relative max-w-full max-h-full flex items-center justify-center">
|
||||
<button
|
||||
class="p-0 border-0 bg-transparent cursor-pointer"
|
||||
onclick={openFullscreen}
|
||||
aria-label="View fullscreen"
|
||||
>
|
||||
<img
|
||||
src={generatedImage}
|
||||
alt="AI generated content"
|
||||
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); downloadImage(); }}
|
||||
aria-label="Download image"
|
||||
>
|
||||
<svg class="w-5 h-5" 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-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center text-txtsecondary">
|
||||
<p>Enter a prompt below to generate an image</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Prompt input area -->
|
||||
<div class="shrink-0 flex flex-col md:flex-row gap-2">
|
||||
<ExpandableTextarea
|
||||
bind:value={prompt}
|
||||
placeholder="Describe the image you want to generate..."
|
||||
rows={3}
|
||||
onkeydown={handleKeyDown}
|
||||
disabled={isGenerating || !$selectedModelStore}
|
||||
/>
|
||||
<div class="flex flex-row md:flex-col gap-2">
|
||||
{#if isGenerating}
|
||||
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none"
|
||||
onclick={generate}
|
||||
disabled={!prompt.trim() || !$selectedModelStore}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen dialog -->
|
||||
{#if showFullscreen && generatedImage}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||
onclick={(e) => closeFullscreen(e)}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeFullscreen()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
onclick={() => closeFullscreen()}
|
||||
aria-label="Close fullscreen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={generatedImage}
|
||||
alt="AI generated content"
|
||||
class="max-w-full max-h-full object-contain pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { models } from "../../stores/api";
|
||||
import { groupModels } from "../../lib/modelUtils";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value = $bindable(), placeholder = "Select a model...", disabled = false }: Props = $props();
|
||||
|
||||
let grouped = $derived(groupModels($models));
|
||||
let hasModels = $derived(grouped.local.length > 0 || Object.keys(grouped.peersByProvider).length > 0);
|
||||
</script>
|
||||
|
||||
{#if hasModels}
|
||||
<select
|
||||
class="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
bind:value
|
||||
{disabled}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{#if grouped.local.length > 0}
|
||||
<optgroup label="Local">
|
||||
{#each grouped.local as model (model.id)}
|
||||
<option value={model.id}>{model.id}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
|
||||
<optgroup label="Peer: {peerId}">
|
||||
{#each peerModels as model (model.id)}
|
||||
<option value={model.id}>{model.id}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
featureName: string;
|
||||
}
|
||||
|
||||
let { featureName }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-txtsecondary">
|
||||
<p class="text-lg">{featureName}</p>
|
||||
<p class="text-sm mt-2">To be implemented</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,373 @@
|
||||
<script lang="ts">
|
||||
import { models } from "../../stores/api";
|
||||
import { persistentStore } from "../../stores/persistent";
|
||||
import { generateSpeech } from "../../lib/speechApi";
|
||||
import ModelSelector from "./ModelSelector.svelte";
|
||||
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||
|
||||
const selectedModelStore = persistentStore<string>("playground-speech-model", "");
|
||||
const selectedVoiceStore = persistentStore<string>("playground-speech-voice", "coral");
|
||||
const autoPlayStore = persistentStore<boolean>("playground-speech-autoplay", false);
|
||||
|
||||
let inputText = $state("");
|
||||
let isGenerating = $state(false);
|
||||
let generatedAudioUrl = $state<string | null>(null);
|
||||
let generatedText = $state<string | null>(null);
|
||||
let generatedVoice = $state<string | null>(null);
|
||||
let generatedTimestamp = $state<Date | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let abortController = $state<AbortController | null>(null);
|
||||
let audioElement = $state<HTMLAudioElement | null>(null);
|
||||
let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||
let isLoadingVoices = $state(false);
|
||||
|
||||
// Default voices to fall back to if API call fails
|
||||
const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
||||
const CACHE_KEY = "playground-speech-voices-cache";
|
||||
|
||||
// Load voices cache from localStorage
|
||||
function getVoicesCache(): Record<string, string[]> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const saved = localStorage.getItem(CACHE_KEY);
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Save voices cache to localStorage
|
||||
function saveVoicesCache(cache: Record<string, string[]>) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch (e) {
|
||||
console.error("Error saving voices cache", e);
|
||||
}
|
||||
}
|
||||
|
||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||
|
||||
// Track if this is the initial page load to avoid fetching on refresh
|
||||
let isInitialLoad = $state(true);
|
||||
|
||||
// On page load, restore cached voices for the selected model if available
|
||||
$effect(() => {
|
||||
const model = $selectedModelStore;
|
||||
|
||||
if (isInitialLoad) {
|
||||
isInitialLoad = false;
|
||||
// If we have cached voices for this model, use them
|
||||
const cache = getVoicesCache();
|
||||
if (model && cache[model]) {
|
||||
availableVoices = cache[model];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshVoices() {
|
||||
const model = $selectedModelStore;
|
||||
if (!model || isLoadingVoices) return;
|
||||
|
||||
isLoadingVoices = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/v1/audio/voices?model=${encodeURIComponent(model)}`);
|
||||
if (!response.ok) {
|
||||
// Fall back to default voices if API call fails
|
||||
availableVoices = defaultVoices;
|
||||
const cache = getVoicesCache();
|
||||
cache[model] = defaultVoices;
|
||||
saveVoicesCache(cache);
|
||||
selectedVoiceStore.set(defaultVoices[0]);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
// Expect response to be an array of voice strings or an object with a voices array
|
||||
const voices = Array.isArray(data) ? data : (data.voices || defaultVoices);
|
||||
const newVoices = voices.length > 0 ? voices : defaultVoices;
|
||||
|
||||
availableVoices = newVoices;
|
||||
const cache = getVoicesCache();
|
||||
cache[model] = newVoices;
|
||||
saveVoicesCache(cache);
|
||||
|
||||
// Reset to first available voice
|
||||
selectedVoiceStore.set(newVoices[0]);
|
||||
} catch {
|
||||
// Fall back to default voices on error
|
||||
availableVoices = defaultVoices;
|
||||
const cache = getVoicesCache();
|
||||
cache[model] = defaultVoices;
|
||||
saveVoicesCache(cache);
|
||||
selectedVoiceStore.set(defaultVoices[0]);
|
||||
} finally {
|
||||
isLoadingVoices = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleVoiceChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
if (value === "(refresh)") {
|
||||
refreshVoices();
|
||||
} else {
|
||||
selectedVoiceStore.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-play effect when new audio is generated
|
||||
$effect(() => {
|
||||
if (generatedAudioUrl && $autoPlayStore && audioElement) {
|
||||
audioElement.load();
|
||||
audioElement.play().catch(() => {
|
||||
// Ignore auto-play errors (e.g., browser policy blocks)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function generate() {
|
||||
const trimmedText = inputText.trim();
|
||||
if (!trimmedText || !$selectedModelStore || isGenerating) return;
|
||||
|
||||
isGenerating = true;
|
||||
error = null;
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const audioBlob = await generateSpeech(
|
||||
$selectedModelStore,
|
||||
trimmedText,
|
||||
$selectedVoiceStore,
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
// Revoke previous URL to prevent memory leaks
|
||||
if (generatedAudioUrl) {
|
||||
URL.revokeObjectURL(generatedAudioUrl);
|
||||
}
|
||||
|
||||
// Create object URL for the audio blob and store metadata
|
||||
generatedAudioUrl = URL.createObjectURL(audioBlob);
|
||||
generatedText = trimmedText;
|
||||
generatedVoice = $selectedVoiceStore;
|
||||
generatedTimestamp = new Date();
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// User cancelled
|
||||
} else {
|
||||
error = err instanceof Error ? err.message : "An error occurred";
|
||||
}
|
||||
} finally {
|
||||
isGenerating = false;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelGeneration() {
|
||||
abortController?.abort();
|
||||
}
|
||||
|
||||
function clearAudio() {
|
||||
if (generatedAudioUrl) {
|
||||
URL.revokeObjectURL(generatedAudioUrl);
|
||||
}
|
||||
generatedAudioUrl = null;
|
||||
generatedText = null;
|
||||
generatedVoice = null;
|
||||
generatedTimestamp = null;
|
||||
error = null;
|
||||
inputText = "";
|
||||
}
|
||||
|
||||
function clearInput() {
|
||||
inputText = "";
|
||||
}
|
||||
|
||||
function downloadAudio() {
|
||||
if (!generatedAudioUrl) return;
|
||||
|
||||
const timestamp = (generatedTimestamp || new Date()).toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||
const voice = generatedVoice || 'speech';
|
||||
const filename = `${voice}-${timestamp}.mp3`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = generatedAudioUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
generate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Model and voice selectors -->
|
||||
<div class="shrink-0 flex gap-2 mb-4">
|
||||
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} />
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
class="shrink-0 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
value={$selectedVoiceStore}
|
||||
onchange={handleVoiceChange}
|
||||
disabled={isGenerating || isLoadingVoices || !$selectedModelStore}
|
||||
>
|
||||
{#each availableVoices as voice (voice)}
|
||||
<option value={voice}>{voice}</option>
|
||||
{/each}
|
||||
<option value="(refresh)">(refresh)</option>
|
||||
</select>
|
||||
{#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]}
|
||||
<button
|
||||
class="btn shrink-0"
|
||||
onclick={refreshVoices}
|
||||
disabled={isLoadingVoices}
|
||||
title={isLoadingVoices ? "Loading voices..." : "Load voices for this model"}
|
||||
>
|
||||
{#if isLoadingVoices}
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" 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"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for no models configured -->
|
||||
{#if !hasModels}
|
||||
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||
<p>No models configured. Add models to your configuration to generate speech.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Audio display area -->
|
||||
<div class="shrink-0 mb-4 bg-surface border border-gray-200 dark:border-white/10 rounded p-4 md:p-6">
|
||||
{#if isGenerating}
|
||||
<div class="flex items-center justify-center text-txtsecondary py-8">
|
||||
<div class="text-center">
|
||||
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||
<p>Generating speech...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-center text-red-500">
|
||||
<p class="font-medium">Error</p>
|
||||
<p class="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if generatedAudioUrl}
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Header with metadata and download -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap gap-3 text-sm text-txtsecondary">
|
||||
{#if generatedVoice}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
|
||||
</svg>
|
||||
{generatedVoice}
|
||||
</span>
|
||||
{/if}
|
||||
{#if generatedTimestamp}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{formatTimestamp(generatedTimestamp)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="btn shrink-0"
|
||||
onclick={downloadAudio}
|
||||
title="Download audio file"
|
||||
>
|
||||
<svg class="w-5 h-5" 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-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio player with larger controls -->
|
||||
<div class="w-full">
|
||||
<audio bind:this={audioElement} controls class="w-full h-12 md:h-16">
|
||||
<source src={generatedAudioUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center text-txtsecondary py-8">
|
||||
<div class="text-center">
|
||||
<svg class="w-12 h-12 md:w-16 md:h-16 mx-auto mb-2 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
|
||||
</svg>
|
||||
<p>Enter text below to convert to speech</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Text input area -->
|
||||
<div class="flex-1 flex flex-col md:flex-row gap-2 min-h-0">
|
||||
<ExpandableTextarea
|
||||
bind:value={inputText}
|
||||
placeholder="Enter text to convert to speech..."
|
||||
rows={8}
|
||||
onkeydown={handleKeyDown}
|
||||
disabled={isGenerating || !$selectedModelStore}
|
||||
/>
|
||||
<div class="shrink-0 flex md:flex-col gap-2">
|
||||
{#if isGenerating}
|
||||
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none"
|
||||
onclick={generate}
|
||||
disabled={!inputText.trim() || !$selectedModelStore}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
class="btn flex-1 md:flex-none"
|
||||
onclick={clearInput}
|
||||
disabled={!inputText.trim()}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<label class="flex items-center justify-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$autoPlayStore}
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
Auto-play
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "katex/dist/katex.min.css";
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AudioTranscriptionResponse } from "./types";
|
||||
|
||||
export async function transcribeAudio(
|
||||
model: string,
|
||||
file: File,
|
||||
signal?: AbortSignal
|
||||
): Promise<AudioTranscriptionResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("model", model);
|
||||
|
||||
const response = await fetch("/v1/audio/transcriptions", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Audio API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ChatMessage, ChatCompletionRequest } from "./types";
|
||||
|
||||
export interface StreamChunk {
|
||||
content: string;
|
||||
reasoning_content?: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
function parseSSELine(line: string): StreamChunk | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith("data: ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = trimmed.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
return { content: "", done: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
const content = delta?.content || "";
|
||||
const reasoning_content = delta?.reasoning_content || "";
|
||||
|
||||
if (content || reasoning_content) {
|
||||
return { content, reasoning_content, done: false };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamChatCompletion(
|
||||
model: string,
|
||||
messages: ChatMessage[],
|
||||
signal?: AbortSignal,
|
||||
options?: ChatOptions
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
const request: ChatCompletionRequest = {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: options?.temperature,
|
||||
};
|
||||
|
||||
const response = await fetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Chat API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
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 result = parseSSELine(line);
|
||||
if (result?.done) {
|
||||
yield result;
|
||||
return;
|
||||
}
|
||||
if (result) {
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer
|
||||
const result = parseSSELine(buffer);
|
||||
if (result && !result.done) {
|
||||
yield result;
|
||||
}
|
||||
|
||||
yield { content: "", done: true };
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ImageGenerationRequest, ImageGenerationResponse } from "./types";
|
||||
|
||||
export async function generateImage(
|
||||
model: string,
|
||||
prompt: string,
|
||||
size: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ImageGenerationResponse> {
|
||||
const request: ImageGenerationRequest = {
|
||||
model,
|
||||
prompt,
|
||||
n: 1,
|
||||
size,
|
||||
};
|
||||
|
||||
const response = await fetch("/v1/images/generations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Image API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderMarkdown, escapeHtml } from "./markdown";
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
describe("basic markdown", () => {
|
||||
it("renders plain text", () => {
|
||||
const result = renderMarkdown("Hello world");
|
||||
expect(result).toContain("Hello world");
|
||||
});
|
||||
|
||||
it("renders bold text", () => {
|
||||
const result = renderMarkdown("**bold**");
|
||||
expect(result).toContain("<strong>bold</strong>");
|
||||
});
|
||||
|
||||
it("renders italic text", () => {
|
||||
const result = renderMarkdown("*italic*");
|
||||
expect(result).toContain("<em>italic</em>");
|
||||
});
|
||||
|
||||
it("renders code blocks", () => {
|
||||
const result = renderMarkdown("```js\nconst x = 1;\n```");
|
||||
expect(result).toContain("hljs");
|
||||
expect(result).toContain("const");
|
||||
});
|
||||
|
||||
it("returns empty string for empty content", () => {
|
||||
const result = renderMarkdown("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null/undefined content", () => {
|
||||
// @ts-expect-error - testing null input
|
||||
expect(renderMarkdown(null)).toBe("");
|
||||
// @ts-expect-error - testing undefined input
|
||||
expect(renderMarkdown(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KaTeX math rendering", () => {
|
||||
it("renders inline math with $...$ syntax", () => {
|
||||
const result = renderMarkdown("The equation $E = mc^2$ is famous.");
|
||||
// KaTeX should convert this to HTML with katex class
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("E");
|
||||
expect(result).toContain("=");
|
||||
expect(result).toContain("mc");
|
||||
});
|
||||
|
||||
it("renders display math with $$...$$ syntax", () => {
|
||||
const result = renderMarkdown("$$\\int_{a}^{b} f(x) dx$$");
|
||||
// Math should be rendered with KaTeX
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("∫");
|
||||
expect(result).toContain("f(x)");
|
||||
});
|
||||
|
||||
it("renders complex LaTeX expressions", () => {
|
||||
const result = renderMarkdown("$$\\sum_{i=1}^{n} x_i = \\frac{1}{n}\\sum_{i=1}^{n} x_i$$");
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("∑"); // or the MathML equivalent
|
||||
});
|
||||
|
||||
it("renders LaTeX with Greek letters", () => {
|
||||
const result = renderMarkdown("$\\alpha + \\beta = \\gamma$");
|
||||
expect(result).toContain("katex");
|
||||
// Greek letters should be rendered
|
||||
expect(result).toMatch(/[αβγ]|alpha|beta|gamma/);
|
||||
});
|
||||
|
||||
it("renders LaTeX with fractions", () => {
|
||||
const result = renderMarkdown("$\\frac{a}{b}$");
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("frac");
|
||||
});
|
||||
|
||||
it("renders LaTeX with subscripts and superscripts", () => {
|
||||
const result = renderMarkdown("$x^2 + y_3$");
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("sup"); // superscript
|
||||
expect(result).toContain("sub"); // subscript
|
||||
});
|
||||
|
||||
it("renders multiple inline math expressions in one paragraph", () => {
|
||||
const result = renderMarkdown("First $x = 1$ and then $y = 2$.");
|
||||
// Should contain multiple katex spans
|
||||
const katexMatches = result.match(/katex/g);
|
||||
expect(katexMatches?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("renders math within a larger markdown document", () => {
|
||||
const markdown = `# Heading
|
||||
|
||||
This is a paragraph with $E = mc^2$ inline math.
|
||||
|
||||
$$\\int_0^\\infty e^{-x} dx = 1$$
|
||||
|
||||
More text here.
|
||||
`;
|
||||
const result = renderMarkdown(markdown);
|
||||
expect(result).toContain("<h1>Heading</h1>");
|
||||
expect(result).toContain("katex");
|
||||
// Both inline and display math should be rendered
|
||||
expect(result).toContain("E = mc");
|
||||
expect(result).toContain("∫");
|
||||
expect(result).toContain("∞");
|
||||
});
|
||||
|
||||
it("handles escaped dollar signs", () => {
|
||||
const result = renderMarkdown("This costs \\$5 and $x = 1$.");
|
||||
// Should render the escaped $5 as text and the math
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("$5");
|
||||
});
|
||||
|
||||
it("handles empty math expressions gracefully", () => {
|
||||
// Empty math should not break the renderer
|
||||
const result = renderMarkdown("$$$");
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders LaTeX matrices", () => {
|
||||
const result = renderMarkdown("$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$");
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("pmatrix");
|
||||
});
|
||||
|
||||
it("renders LaTeX square roots", () => {
|
||||
const result = renderMarkdown("$\\sqrt{x^2 + y^2}$");
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("sqrt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("escapes HTML entities", () => {
|
||||
expect(escapeHtml("<script>")).toBe("<script>");
|
||||
expect(escapeHtml('"quoted"')).toBe(""quoted"");
|
||||
expect(escapeHtml("'single'")).toBe("'single'");
|
||||
expect(escapeHtml("a & b")).toBe("a & b");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(escapeHtml("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("does not throw on invalid LaTeX syntax", () => {
|
||||
// Invalid LaTeX should not crash the renderer
|
||||
expect(() => renderMarkdown("$\\invalidcommand{")).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns fallback HTML on processing errors", () => {
|
||||
// Very large or malformed input should be handled
|
||||
const result = renderMarkdown("$" + "a".repeat(10000) + "$");
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
import hljs from "highlight.js";
|
||||
import { visit } from "unist-util-visit";
|
||||
import type { Element, Root } from "hast";
|
||||
|
||||
// Custom plugin to highlight code blocks with highlight.js
|
||||
function rehypeHighlight() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, "element", (node: Element) => {
|
||||
if (node.tagName === "code" && node.properties) {
|
||||
const className = node.properties.className;
|
||||
const classes = Array.isArray(className)
|
||||
? className.filter((c): c is string => typeof c === "string")
|
||||
: [];
|
||||
const lang = classes
|
||||
.find((c) => c.startsWith("language-"))
|
||||
?.replace("language-", "");
|
||||
|
||||
const text = node.children
|
||||
.filter((child): child is { type: "text"; value: string } => child.type === "text")
|
||||
.map((child) => child.value)
|
||||
.join("");
|
||||
|
||||
if (text) {
|
||||
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
||||
const highlighted = hljs.highlight(text, { language }).value;
|
||||
|
||||
// Replace the text node with raw HTML
|
||||
node.properties.className = [
|
||||
"hljs",
|
||||
`language-${language}`,
|
||||
...classes.filter((c) => !c.startsWith("language-")),
|
||||
];
|
||||
// Use type assertion since we're modifying the tree structure
|
||||
(node.children as unknown) = [
|
||||
{ type: "raw", value: highlighted },
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
const htmlEntities: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (char) => htmlEntities[char]);
|
||||
}
|
||||
|
||||
// Create the unified processor
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkMath)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeKatex)
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify, { allowDangerousHtml: true });
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const result = processor.processSync(content);
|
||||
return String(result);
|
||||
} catch {
|
||||
// Fallback to escaped plain text if markdown parsing fails
|
||||
return `<p>${escapeHtml(content)}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Model } from "./types";
|
||||
|
||||
export interface GroupedModels {
|
||||
local: Model[];
|
||||
peersByProvider: Record<string, Model[]>;
|
||||
}
|
||||
|
||||
export function groupModels(models: Model[]): GroupedModels {
|
||||
const available = models.filter((m) => !m.unlisted);
|
||||
const local = available.filter((m) => !m.peerID);
|
||||
const peerModels = available.filter((m) => m.peerID);
|
||||
|
||||
const peersByProvider = peerModels.reduce(
|
||||
(acc, model) => {
|
||||
const peerId = model.peerID || "unknown";
|
||||
if (!acc[peerId]) acc[peerId] = [];
|
||||
acc[peerId].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Model[]>
|
||||
);
|
||||
|
||||
return { local, peersByProvider };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SpeechGenerationRequest } from "./types";
|
||||
|
||||
export async function generateSpeech(
|
||||
model: string,
|
||||
input: string,
|
||||
voice: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Blob> {
|
||||
const request: SpeechGenerationRequest = {
|
||||
model,
|
||||
input,
|
||||
voice,
|
||||
};
|
||||
|
||||
const response = await fetch("/v1/audio/speech", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Speech API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
@@ -40,3 +40,77 @@ export interface VersionInfo {
|
||||
}
|
||||
|
||||
export type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
|
||||
export type TextContentPart = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ImageContentPart = {
|
||||
type: "image_url";
|
||||
image_url: { url: string };
|
||||
};
|
||||
|
||||
export type ContentPart = TextContentPart | ImageContentPart;
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string | ContentPart[];
|
||||
reasoning_content?: string;
|
||||
reasoningTimeMs?: number;
|
||||
}
|
||||
|
||||
export function getTextContent(content: string | ContentPart[]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
const textParts = content.filter((part): part is TextContentPart => part.type === "text");
|
||||
return textParts.map((part) => part.text).join("\n");
|
||||
}
|
||||
|
||||
export function getImageUrls(content: string | ContentPart[]): string[] {
|
||||
if (typeof content === "string") {
|
||||
return [];
|
||||
}
|
||||
return content
|
||||
.filter((part): part is ImageContentPart => part.type === "image_url")
|
||||
.map((part) => part.image_url.url);
|
||||
}
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
stream: boolean;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
n?: number;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
created: number;
|
||||
data: Array<{
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionRequest {
|
||||
file: File;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionResponse {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SpeechGenerationRequest {
|
||||
model: string;
|
||||
input: string;
|
||||
voice: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./index.css";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { persistentStore } from "../stores/persistent";
|
||||
import ChatInterface from "../components/playground/ChatInterface.svelte";
|
||||
import ImageInterface from "../components/playground/ImageInterface.svelte";
|
||||
import AudioInterface from "../components/playground/AudioInterface.svelte";
|
||||
import SpeechInterface from "../components/playground/SpeechInterface.svelte";
|
||||
|
||||
type Tab = "chat" | "images" | "speech" | "audio";
|
||||
|
||||
const selectedTabStore = persistentStore<Tab>("playground-selected-tab", "chat");
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "chat", label: "Chat" },
|
||||
{ id: "images", label: "Images" },
|
||||
{ id: "speech", label: "Speech" },
|
||||
{ id: "audio", label: "Transcription" },
|
||||
];
|
||||
|
||||
function selectTab(tab: Tab) {
|
||||
selectedTabStore.set(tab);
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function getTabLabel(tabId: Tab): string {
|
||||
return tabs.find(t => t.id === tabId)?.label || "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card h-full flex flex-col">
|
||||
<!-- Tab navigation -->
|
||||
<div class="shrink-0 mb-4">
|
||||
<!-- Mobile: Dropdown menu (hidden on md and up) -->
|
||||
<div class="block md:hidden relative">
|
||||
<button
|
||||
class="w-full px-4 py-2 rounded font-medium transition-colors flex items-center justify-between bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
>
|
||||
<span>{getTabLabel($selectedTabStore)}</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform {mobileMenuOpen ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{#if mobileMenuOpen}
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left hover:bg-secondary-hover transition-colors first:rounded-t last:rounded-b {$selectedTabStore === tab.id ? 'bg-primary/10 font-medium' : ''}"
|
||||
onclick={() => selectTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Tab buttons (shown on md and up) -->
|
||||
<div class="hidden md:flex flex-wrap gap-2">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
class="px-4 py-2 rounded font-medium transition-colors {$selectedTabStore === tab.id
|
||||
? 'bg-primary text-btn-primary-text'
|
||||
: 'bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10'}"
|
||||
onclick={() => selectTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "chat"}>
|
||||
<ChatInterface />
|
||||
</div>
|
||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "images"}>
|
||||
<ImageInterface />
|
||||
</div>
|
||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "speech"}>
|
||||
<SpeechInterface />
|
||||
</div>
|
||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "audio"}>
|
||||
<AudioInterface />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tab-hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,25 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
plugins: [
|
||||
svelte(),
|
||||
tailwindcss(),
|
||||
compression({
|
||||
algorithm: "gzip",
|
||||
exclude: [/\.(br)$/, /\.(gz)$/],
|
||||
threshold: 1024,
|
||||
}),
|
||||
compression({
|
||||
algorithm: "brotliCompress",
|
||||
exclude: [/\.(br)$/, /\.(gz)$/],
|
||||
threshold: 1024,
|
||||
filename: "[path][base].br",
|
||||
}),
|
||||
],
|
||||
base: "/ui/",
|
||||
build: {
|
||||
outDir: "../proxy/ui_dist",
|
||||
@@ -16,6 +31,7 @@ export default defineConfig({
|
||||
"/logs": "http://localhost:8080",
|
||||
"/upstream": "http://localhost:8080",
|
||||
"/unload": "http://localhost:8080",
|
||||
"/v1": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user