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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user