20738f3623
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
85 lines
2.5 KiB
TypeScript
85 lines
2.5 KiB
TypeScript
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>`;
|
|
}
|
|
}
|