feat(ui): split assets, expose stats/controls, surface model metadata#246
feat(ui): split assets, expose stats/controls, surface model metadata#246igorls wants to merge 2 commits into
Conversation
…e metadata
DX
- Split monolithic ui/index.html (2118 lines) into ui/index.html (~180 lines
of structure) + ui/src/app.css + ui/src/app.js + ui/vendor/{marked,highlight}.min.js.
- build.rs walks <link>/<script src> tags and inlines local assets at compile
time, so runtime delivery stays a single gzipped blob (1 MiB budget).
cargo:rerun-if-changed walks ui/ recursively.
UI evolution
- Toggleable right stats panel: SERVER (status, endpoint, models loaded,
active model), MODEL (format, quantization, family, architecture, ctx,
vocab, layers·heads·KV, hidden, modalities), RUNTIME (device, dtype,
TurboQuant KV bits), LAST RESPONSE (TTFT, total, tokens, tok/s, finish,
reasoning chunks), SESSION counters.
- Per-message stats footer on each assistant turn (TTFT · tokens · tok/s · total).
- Live readout at the bottom of the input area while streaming.
- Sampling-summary chips above the prompt showing only the active overrides.
- Tabbed Settings modal (Basic / Sampler / Behavior) exposing top-k, min-p,
repetition / frequency / presence penalty, seed (random/clear), stop
sequences, reasoning effort, plus toggles for stats panel default,
reasoning auto-expand, and stream-render markdown.
- Color-coded device badge in the navbar (CPU=amber warning, CUDA=green,
Metal=purple) so users immediately see which path they're on.
- Server status pill polls /health every 5s.
- Loading-state placeholder with elapsed seconds; polls /api/ps to switch
from "Loading model X" to "Model loaded · generating first token" once
the model is actually ready.
- Cancel-load button (also extends the global stop button) — sends
/api/generate keep_alive=0 so the server doesn't keep loading in the
background after the user changes their mind.
- Errors now render as a distinct red-bordered system notice rather than
being styled as fake "Assistant" turns and saved to history.
- Per-conversation delete + "Clear all history" + Reset to defaults.
- Strip locally-stored fields (`stats`) from messages before shipping —
OpenAI spec only allows {role, content}.
Server-side metadata exposure
- New ModelMeta struct (format, quantization, family, architecture, dtype,
device, context_length, vocab_size, layer/head shape, turbo_quant_bits,
has_audio/vision) built at load time from EngineContext.
- /api/show now returns inferrs_meta + populates Ollama details.format,
details.family, details.quantization_level. Daemon mode proxies the
request to the worker so accurate meta reaches the client.
- Quantization heuristic from model id covers Q4_K_M, Q8_0, AWQ-4bit,
GPTQ-int4, FP16, BF16, FP8, etc. without pulling in a regex crate.
There was a problem hiding this comment.
Pull request overview
This PR refactors the built-in web UI into split assets that are bundled/inlined at compile time, expands the UI to expose runtime/model stats and advanced sampling controls, and adds server-side model metadata surfaced via /api/show for the UI and Ollama-compat clients.
Changes:
- Split UI into
ui/index.html+ui/src/{app.css,app.js}+ vendoredmarked/highlight.js, withbuild.rsinlining local assets into a single gzipped HTML blob at build time. - Add a richer UI: stats panel (server/model/runtime/last response/session), device badge, streaming status/readout, settings modal for advanced sampling and behavior toggles, and clearer error notices.
- Add server-side
ModelMetacaptured at load time and returned from/api/show(and proxy/api/showto the worker in daemon mode); extendEngineContextwith fields needed to build this snapshot.
Reviewed changes
Copilot reviewed 5 out of 8 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
inferrs/ui/vendor/marked.min.js |
Vendor markdown parser added for UI markdown rendering. |
inferrs/ui/vendor/highlight.min.js |
Vendor highlight.js added to support code highlighting in UI. |
inferrs/ui/src/app.js |
New main UI logic: chat, streaming, settings, stats, metadata fetching, error handling. |
inferrs/ui/src/app.css |
New UI styling including stats panel, settings modal, device badge, and error notice styles. |
inferrs/src/server.rs |
Add ModelMeta, include it in /api/show, and proxy /api/show to worker in daemon mode. |
inferrs/src/engine.rs |
Extend EngineContext with device/quant/gguf/turbo-quant fields for metadata reporting. |
inferrs/build.rs |
Implement UI bundling by inlining local <link>/<script src> assets then gzipping. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Code Review
This pull request implements a Web UI bundling system in the build script and introduces a new browser interface for model interaction. It also updates the engine and server to capture and report detailed model metadata, such as architecture and device information, via the /api/show endpoint. The review feedback highlights critical security issues, including a path traversal vulnerability in the build script and an XSS risk in the Web UI's markdown rendering.
| // Leave external references untouched. | ||
| out.push_str(&html[abs_start..after_tag]); | ||
| } else { | ||
| let path = PathBuf::from(UI_DIR).join(url); |
There was a problem hiding this comment.
The asset inlining logic is vulnerable to path traversal. The url extracted from the HTML is joined with the ui/ directory without validation. A malicious index.html could use relative paths (e.g., ../../etc/passwd) or absolute paths to read and inline sensitive files from the build environment into the final binary. This constitutes a supply chain risk as defined in the repository style guide.
| let path = PathBuf::from(UI_DIR).join(url); | |
| let url_path = std::path::Path::new(url); | |
| if url_path.is_absolute() || url_path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { | |
| panic!("malicious path in UI asset: {}", url); | |
| } | |
| let path = PathBuf::from(UI_DIR).join(url); |
References
- Security — What are the threat surfaces? ... Is input validated at system boundaries? ... Think about the OWASP top 10, supply chain risks... (link)
| + thinking.length + ' block' + (thinking.length > 1 ? 's' : '') + ')</summary><div>' | ||
| + escHtml(thinking.join('\n\n')) + '</div></details>'; | ||
| } | ||
| html += marked.parse(cleaned); |
There was a problem hiding this comment.
The assistant's output is rendered as HTML using marked.parse() and injected into the DOM via innerHTML without any sanitization. This allows a malicious or compromised model to execute arbitrary JavaScript in the user's browser (XSS). Since this UI is intended for technical users who may interact with untrusted models, this is a significant security risk. Please use a sanitization library like DOMPurify to clean the HTML before injection.
References
- Security — What are the threat surfaces? ... Think about the OWASP top 10... (link)
Confidence Score: 3/5Safe to merge after addressing the absolute-path guard in One P1 (absolute-path bypass in build script poses a supply-chain integrity risk) and two P2s (attribute-order brittleness in the bundler; unsanitized
|
| Filename | Overview |
|---|---|
| inferrs/build.rs | New HTML-bundling logic inlines CSS/JS at build time; attribute-order rigidity and missing absolute-path guard in is_external are two logic issues. |
| inferrs/src/server.rs | Adds ModelMeta struct, build_model_meta, daemon proxy for /api/show, and populates Ollama details fields; logic is sound, proxy and serde skip conditions behave correctly. |
| inferrs/src/engine.rs | Adds device, quant_dtype, gguf_loaded, and turbo_quant_bits to EngineContext; straightforward metadata capture with no logic issues. |
| inferrs/ui/src/app.js | New 1073-line UI script; overall well-structured, but marked.parse output is set as innerHTML without sanitization (XSS risk from LLM output), and minor null-contract issue in computeStats. |
| inferrs/ui/index.html | Monolithic HTML split into skeleton with <link>/<script> references that build.rs inlines; structure is correct for the bundling approach. |
| inferrs/ui/src/app.css | New CSS file extracted from the monolithic HTML; new styles for stats panel, device badge, sampling chips, and error notices look correct. |
Reviews (1): Last reviewed commit: "feat(ui): split assets, expose stats/con..." | Re-trigger Greptile
| fn is_external(url: &str) -> bool { | ||
| url.starts_with("http://") | ||
| || url.starts_with("https://") | ||
| || url.starts_with("//") | ||
| || url.starts_with("data:") | ||
| } |
There was a problem hiding this comment.
Absolute paths bypass
is_external guard
is_external only rejects http://, https://, //, and data: URLs. An absolute path like /etc/shadow passes through, and PathBuf::from("ui").join("/etc/shadow") resolves to /etc/shadow (Rust's PathBuf::join replaces the accumulated path when the new component is absolute). If ui/index.html ever references an absolute href/src, the build script would embed arbitrary host files into the binary at compile time — a silent supply-chain risk in CI.
Add an absolute-path check:
| fn is_external(url: &str) -> bool { | |
| url.starts_with("http://") | |
| || url.starts_with("https://") | |
| || url.starts_with("//") | |
| || url.starts_with("data:") | |
| } | |
| fn is_external(url: &str) -> bool { | |
| url.starts_with("http://") | |
| || url.starts_with("https://") | |
| || url.starts_with("//") | |
| || url.starts_with("data:") | |
| || url.starts_with('/') | |
| } |
| let html = inline_tag( | ||
| html, | ||
| // <link rel="stylesheet" href="..."> — order-tolerant: rel before href | ||
| r#"<link rel="stylesheet" href=""#, | ||
| r#"">"#, | ||
| |body| format!("<style>\n{body}\n</style>"), | ||
| ); | ||
| inline_tag( | ||
| &html, | ||
| r#"<script src=""#, | ||
| r#"></script>"#, | ||
| |body| format!("<script>\n{body}\n</script>"), | ||
| ) |
There was a problem hiding this comment.
Strict attribute-order assumption in link-tag matching
inline_tag searches for the literal prefix <link rel="stylesheet" href=", so it only inlines CSS when rel comes before href. If a developer writes <link href="src/app.css" rel="stylesheet"> (or a tool reorders the attributes), the <link> tag is left verbatim in the bundle. The browser then tries to GET /src/app.css which the server doesn't serve — the page renders without styles, and the build succeeds at the 1 MiB budget check with no warning.
Consider matching on href alone (or at least asserting a <link rel="stylesheet" is present and inlined at the end of the build step).
| html += marked.parse(cleaned); | ||
| return html; |
There was a problem hiding this comment.
LLM-generated HTML rendered without sanitization
marked.parse(cleaned) is piped directly into assistantBody.innerHTML. Because marked v4+ no longer auto-strips raw HTML (the old sanitize option was removed), a model response containing <script>alert(1)</script> or <img src=x onerror=...> would execute in the browser context. Although the immediate threat model is local-only, a jailbroken or model-poisoned response could exfiltrate localStorage (which holds conversation history and settings) or trigger unexpected side-effects.
Consider adding a lightweight sanitizer such as DOMPurify or replacing arbitrary HTML nodes with a safe subset of tags via marked's walkTokens / renderer hooks.
…ibute order, fmt
Security
- build.rs: reject absolute paths and `..` components when resolving asset
URLs to be inlined. An absolute `href`/`src` made `PathBuf::from("ui").
join("/etc/shadow")` resolve to `/etc/shadow` and silently embed host
files into the binary at compile time. Treat such URLs as a hard build
failure. Strip `?` / `#` from URLs before resolving. Flagged P1 by both
greptile and gemini bots.
- ui/src/app.js: marked v15 dropped its `sanitize` option, so raw HTML in
model output (`<script>`, `onerror=`, `javascript:` URLs) was reaching
`innerHTML`. Override `marked.Renderer` to (1) escape `html` tokens
rather than emit them verbatim, and (2) drop `javascript:` / `data:` /
`vbscript:` URLs in links and images. Verified in-browser with a
payload containing `<script>`, `<img onerror>`, and a `javascript:`
link — none execute, legitimate markdown still renders.
Correctness
- build.rs: tag matcher is now tolerant of attribute order — previously
`<link href="x" rel="stylesheet">` (href before rel) would not be
inlined, the daemon doesn't serve those paths, and the UI shipped
unstyled. Walks the tag, parses attributes individually, requires
`rel="stylesheet"` for `<link>` and `src=…` for `<script>`.
- build.rs: belt-and-suspenders post-bundle assertion fails the build
if any local `<link href>` / `<script src>` survives the bundling pass.
Lint
- Apply `cargo fmt` (CI was failing on `cargo fmt --check`).
|
Addressed all review feedback in Security
Correctness
Lint
|
Summary
ui/index.html(2118 lines) intoindex.html+src/app.css+src/app.js+vendor/{marked,highlight}.min.js.build.rswalks<link>/<script src>tags and inlines local assets at compile time, so runtime delivery stays a single gzipped blob (currently ~71 KiB, well under the existing 1 MiB budget).cargo:rerun-if-changedwalksui/recursively./api/psand switches to "Model loaded · generating first token" once the model is actually loaded (so users don't think a slow first token means the load is still running). Cancel-load button + extended global stop button send/api/generate keep_alive=0so the server unloads instead of finishing the load in the background.ModelMeta(format / quantization / family / architecture / dtype / device / context_length / vocab_size / shape /turbo_quant_bits/ modalities) built at load time fromEngineContext./api/showreturns it underinferrs_metaand also populates the Ollamadetails.{format,family,quantization_level}fields for ecosystem clients. Daemon proxies/api/showto the worker so accurate meta reaches the client.stats) from messages before shipping to/v1/chat/completions— the OpenAI spec only allows{role, content}and strict servers will 400 on extras.Test plan
cargo build -p inferrs --bin inferrs— bundle log shows ~71 KiBcargo build -p inferrs --bin inferrs --features cuda(withPATH=/usr/local/cuda/bin) — compilesGET /in browser, confirm UI renders, toggles work, no console errors (other than expected favicon 404)/v1/models+/health; MODEL/RUNTIME populated from/api/showafter a successful generation; LAST RESPONSE updates per turn; SESSION counters updateCUDA · F16/CPU · F32/ etc. with informative tooltiplanguage-pythonsyntax highlight + Copy button<think>...</think>reasoning that becomes a collapsible disclosureaborted, finally cleans uptemperature,top_k,min_p,repetition_penalty,seed,stop[]Model 'X' unloaded via stop request, UI cleanly drops the placeholderNotes
Pre-existing model bugs surfaced during testing (NOT introduced here, listed for visibility):
ggml-org/gemma-4-26B-A4B-it-GGUF—tensor not found: model.language_model.layers.0.router.proj.weightcyankiwi/gemma-4-26B-A4B-it-AWQ-4bit—Failed to parse config.jsonunsloth/Phi-4-mini-instruct-GGUF— missingtokenizer.jsonon HF (404)Follow-ups worth considering (out of scope here):
quant_methodfield from HFquantization_configfor safetensors-AWQ / GPTQ models (right now we fall back to the model-id heuristic for those)stream_options.include_usage) so the per-message footer can be exact instead of "tokens ≈ chunks"