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:
Benson Wong
2026-01-31 22:49:13 -08:00
committed by GitHub
parent cdea7d16bd
commit 20738f3623
65 changed files with 5031 additions and 6078 deletions
+24
View File
@@ -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();
}
+108
View File
@@ -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();
}
}
+31
View File
@@ -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();
}
+160
View File
@@ -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("&lt;script&gt;");
expect(escapeHtml('"quoted"')).toBe("&quot;quoted&quot;");
expect(escapeHtml("'single'")).toBe("&#39;single&#39;");
expect(escapeHtml("a & b")).toBe("a &amp; 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();
});
});
});
+84
View File
@@ -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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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>`;
}
}
+24
View File
@@ -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 };
}
+30
View File
@@ -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();
}
+74
View File
@@ -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;
}