Files
llmgw/internal/web/templates/chat.html
T
Ilker ManapandClaude Opus 5.5 a133270725 The chat's kept conversation belongs to the account that kept it
It sat in localStorage under one fixed key, so the next person to sign in
on the same browser opened the chat on the last one's conversation. It is
now kept under the account's id, anyone else's is dropped when the page
loads, and signing out sends Clear-Site-Data: "storage" so nothing the
pages kept outlives the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 15:44:47 +02:00

566 lines
25 KiB
HTML

{{define "chat"}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "chat.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<link rel="stylesheet" href="/_static/katex/katex.min.css">
<script defer src="/_static/katex/katex.min.js"></script>
<script defer src="/_static/katex/auto-render.min.js"></script>
<script defer src="/_static/pages.js"></script>
<meta name="csrf" content="{{.CSRF}}">
<meta name="chat-owner" content="{{.User.ID}}">
</head><body><div class="wrap">
<header>
<h1>{{t "chat.title"}}</h1>
<p class="sub">{{.User.Email}}</p>
{{template "nav" .Nav}}
</header>
{{if gt (len .Contexts) 1}}
{{/* What this conversation spends against. A plain GET, so the model list
below is the one this choice allows; the server checks it again on every
message rather than trusting the page. */}}
<form method="get" action="/chat" class="chat-context">
<label class="inline-label">{{t "chat.context.using"}}
<select id="context" name="context" data-autosubmit>
{{range .Contexts}}<option value="{{.Value}}"{{if eq .Value $.Context}} selected{{end}}>{{.Label}}</option>{{end}}
</select></label>
{{/* translate="no" only because a parser reads noscript as raw text and
would take the markup for prose; the label is translated. */}}
<noscript translate="no"><button type="submit" class="link">{{t "chat.context.switch"}}</button></noscript>
</form>
{{else if .Contexts}}{{with index .Contexts 0}}<input type="hidden" id="context" value="{{.Value}}">{{end}}{{end}}
{{if .Models}}
<div class="chat-bar">
<label class="inline-label">{{t "chat.model"}}
<select id="model">{{range .Models}}<option value="{{.Name}}" data-simulated="{{if .Simulated}}1{{else}}0{{end}}" data-ondemand="{{if .OnDemand}}1{{else}}0{{end}}">{{.Name}}{{with .Runs}} · {{.}}{{end}}{{if .Simulated}} {{t "chat.model.simulated"}}{{end}}{{if .OnDemand}} {{t "chat.model.not_loaded"}}{{end}}</option>{{end}}</select>
</label>
{{if .Collections}}<label class="inline-label">{{t "chat.knowledge"}}
<select id="knowledge">
{{if .KnowledgeDefault}}<option value="">{{t "chat.knowledge.default"}} ({{.KnowledgeDefault}})</option>
<option value="none">{{t "chat.knowledge.none"}}</option>{{else}}<option value="none">{{t "chat.knowledge.none"}}</option>{{end}}
{{range .Collections}}<option value="{{.ID}}">{{.Label}}</option>{{end}}
</select>
</label>{{end}}
<button id="reset" type="button" class="link">{{t "chat.new"}}</button>
</div>
<details class="chat-settings" id="settings">
<summary>{{t "chat.settings"}}</summary>
<label>{{t "chat.settings.system"}} <span class="opt">{{t "chat.settings.system_hint"}}</span>
<textarea id="system" rows="2" placeholder="{{t "chat.settings.system_placeholder"}}"></textarea></label>
<label class="temp">{{t "chat.settings.temperature"}} <output id="temp-out">{{num 0.7 1}}</output>
<input type="range" id="temperature" min="0" max="2" step="0.1" value="0.7">
<span class="opt">{{t "chat.settings.temperature_hint"}}</span></label>
</details>
<p id="sim-note" class="note" hidden>{{t "chat.note.simulated"}}</p>
<p id="ondemand-note" class="note" hidden>{{t "chat.note.on_demand"}}</p>
<div id="log" class="chat-log" aria-live="polite"></div>
<form id="composer" class="chat-composer">
<textarea id="input" rows="3" placeholder="{{t "chat.input_placeholder"}}" required></textarea>
<button type="submit" id="send">{{t "chat.send"}}</button>
</form>
<p class="note">{{t "chat.kept"}}</p>
{{else}}
<p class="empty">{{t "chat.empty"}}</p>
<p class="note">{{th "chat.empty_why"}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
</div>
<script>
// What the script says, in the page's language. The template writes each
// text as a JavaScript string; {name} marks where a value goes, so a
// language can put it where its grammar wants it.
const say = {
preparing: {{t "chat.js.preparing"}},
downloadFailed: {{t "chat.js.download_failed"}},
stageWaiting: {{t "chat.js.stage.waiting"}},
stageDownloading: {{t "chat.js.stage.downloading"}},
stageStarting: {{t "chat.js.stage.starting"}},
stageReady: {{t "chat.js.stage.ready"}},
of: {{t "chat.js.of"}},
couldNotStart: {{t "chat.js.could_not_start"}},
tokens: {{t "chat.js.tokens"}},
tokPerS: {{t "chat.js.tok_per_s"}},
firstToken: {{t "chat.js.first_token"}},
seconds: {{t "chat.js.seconds"}},
stopped: {{t "chat.js.stopped"}},
stop: {{t "chat.js.stop"}},
send: {{t "chat.send"}},
refused: {{t "chat.js.refused"}},
downloadZip: {{t "chat.js.download_zip"}},
stoppedByPolicy: {{t "chat.js.stopped_by_policy"}},
cutOff: {{t "chat.js.cut_off"}},
sources: {{t "chat.js.sources"}},
stoppedByPolicyEmpty: {{t "chat.js.stopped_by_policy_empty"}},
stoppedEmpty: {{t "chat.js.stopped_empty"}},
noContent: {{t "chat.js.no_content"}},
decimal: {{t "_meta.decimal"}},
};
// fill puts values into a text from say.
function fill(s, vals) { return s.replace(/\{(\w+)\}/g, (m, k) => (k in vals ? String(vals[k]) : m)); }
// fixed is toFixed with the language's decimal separator.
function fixed(n, places) { return n.toFixed(places).replace(".", say.decimal); }
// The policy a guardrail acted by, when the response says.
function policySuffix(resp) {
const p = resp.headers.get("x-llmgw-guard-policy");
return p ? " " + p : "";
}
// Vanilla, inline, no build step — the same reason the charts are hand-drawn
// SVG. It streams because a chat that hangs for ten seconds and then dumps a
// paragraph feels broken, and on CPU inference ten seconds is normal.
(function () {
const log = document.getElementById("log");
if (!log) return;
const form = document.getElementById("composer");
const input = document.getElementById("input");
const send = document.getElementById("send");
const model = document.getElementById("model");
// From a meta tag rather than interpolated into the script, so the token
// travels through the same HTML escaping as everything else on the page.
const csrf = document.querySelector('meta[name="csrf"]').content;
let messages = [];
// --- settings and the kept conversation --------------------------------
//
// In this browser's storage, and only there: the gateway keeps no
// conversation. Every read and write is guarded — a private window, or a
// browser that refuses storage, simply starts empty each time.
//
// Kept under the account's id: a browser is often shared, and the next
// person to sign in on it must not be handed the last one's conversation.
// Anyone else's left here is dropped on the way in, as is the one from
// before the key carried an owner.
const sys = document.getElementById("system");
const temp = document.getElementById("temperature");
const tempOut = document.getElementById("temp-out");
const PREFIX = "llmgw.chat.v1";
const KEY = PREFIX + "." + document.querySelector('meta[name="chat-owner"]').content;
try {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k && (k === PREFIX || k.startsWith(PREFIX + ".")) && k !== KEY) localStorage.removeItem(k);
}
} catch (_) { /* no storage: nothing to drop */ }
const load_ = () => { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch (_) { return null; } };
const save = () => {
try {
localStorage.setItem(KEY, JSON.stringify({ model: model.value, messages: messages.slice(-60),
system: sys.value, temperature: temp.value }));
} catch (_) { /* no storage: nothing kept */ }
};
temp.addEventListener("input", () => { tempOut.textContent = fixed(+temp.value, 1); save(); });
sys.addEventListener("change", save);
document.getElementById("reset").onclick = () => { messages = []; log.textContent = ""; save(); };
// The note follows the picker rather than the reply: someone should know what
// they are about to get before they spend a minute waiting for it.
const simNote = document.getElementById("sim-note");
const onDemandNote = document.getElementById("ondemand-note");
function showSimNote() {
const opt = model.selectedOptions[0];
simNote.hidden = !opt || opt.dataset.simulated !== "1";
onDemandNote.hidden = !opt || opt.dataset.ondemand !== "1";
}
model.addEventListener("change", showSimNote);
showSimNote();
function bubble(role) {
const el = document.createElement("div");
el.className = "bubble " + role;
// textContent throughout: the reply is model output and the prompt is user
// input, and neither has any business being parsed as markup.
log.appendChild(el);
log.scrollTop = log.scrollHeight;
return el;
}
// Maths is rendered after the text is already in place as text.
//
// That order is the whole safety argument. renderMathInElement walks the text
// nodes of an element whose content was set with textContent, so nothing the
// model wrote is ever parsed as HTML — only the spans KaTeX recognises become
// markup, and that markup is KaTeX's, not the model's.
//
// trust stays false, which is KaTeX's default and worth stating: it is what
// keeps \href, \url and \includegraphics out of a reply.
function renderMath(el) {
// A rendered reply says where its maths is: the server set each span
// aside before the markdown and marked it, so exactly those are typeset.
// KaTeX's own search is the fallback for a reply that stayed plain text,
// and it takes "$5 and $10" for a formula, which the marks do not.
const marked = el.querySelectorAll("span.math");
if (marked.length && typeof katex === "object") {
for (const m of marked) {
try {
katex.render(m.textContent, m, {
displayMode: m.dataset.display === "1", throwOnError: false, trust: false,
});
} catch (e) { m.textContent = m.dataset.src || m.textContent; }
}
return;
}
if (el.classList.contains("md")) return;
if (typeof renderMathInElement !== "function") return;
try {
renderMathInElement(el, {
delimiters: [
{ left: "$$", right: "$$", display: true },
{ left: "\\[", right: "\\]", display: true },
{ left: "\\(", right: "\\)", display: false },
// Single $ last: a model writing about prices produces "$5 and $10",
// and treating that as maths turns a sentence into gibberish. It is
// still enabled because models overwhelmingly use it for maths, and
// ignoreOnError leaves anything that does not parse as plain text.
{ left: "$", right: "$", display: false },
],
throwOnError: false,
errorColor: "var(--bad)",
trust: false,
});
} catch (e) { /* a reply without maths is better than no reply */ }
}
// The finished reply as markdown: headings, lists, tables, code blocks.
//
// Rendered by the server, under the rules the mirror's model cards use —
// raw HTML dropped, dangerous links emptied, images as links — so what
// comes back is safe to place. If that fails the reply stays as the plain
// text it streamed as, which is still the whole answer.
async function renderReply(out, reply) {
try {
const resp = await fetch("/chat/render", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ content: reply }),
});
if (!resp.ok) return;
out.innerHTML = await resp.text();
out.classList.add("md");
for (const a of out.querySelectorAll("a[href]")) {
a.target = "_blank";
a.rel = "noopener noreferrer";
}
} catch (_) { /* plain text it is */ }
}
// Fetched rather than linked, because the archive is built from a POST body
// and there is no URL that names it. The blob is revoked immediately after
// the click: it is the whole reply, and leaving it alive keeps it addressable
// for as long as the tab is open.
async function downloadCode(reply, btn) {
const was = btn.textContent;
btn.disabled = true;
btn.textContent = say.preparing;
try {
const resp = await fetch("/chat/download", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ content: reply, name: "code" }),
});
if (!resp.ok) {
btn.textContent = "(" + resp.status + ") " + (await resp.text()).slice(0, 120);
return;
}
const url = URL.createObjectURL(await resp.blob());
const a = document.createElement("a");
a.href = url;
a.download = (resp.headers.get("Content-Disposition") || "")
.match(/filename="?([^"]+)"?/)?.[1] || "code.zip";
a.click();
URL.revokeObjectURL(url);
btn.textContent = was;
} catch (err) {
btn.textContent = say.downloadFailed;
} finally {
btn.disabled = false;
}
}
const ctxValue = () => (document.getElementById("context") || {}).value || "";
const stages = {
waiting: say.stageWaiting,
downloading: say.stageDownloading,
starting: say.stageStarting,
ready: say.stageReady,
};
function mib(n) { return fixed(n / 1048576, 0) + " MiB"; }
// Loads a model that is not running and shows how far along it is, in the
// reply's own bubble. Resolves true once it answers, false if it cannot.
//
// Asked for with /chat/wake rather than by sending the message and waiting:
// that request gives up after 45 seconds, and a large model takes minutes.
async function load(name, out) {
const box = document.createElement("div");
box.className = "loading";
const label = document.createElement("div");
label.className = "stage";
const bar = document.createElement("progress");
bar.max = 100;
box.append(label, bar);
out.replaceChildren(box);
const started = Date.now();
let failures = 0;
let st;
try {
const resp = await fetch("/chat/wake", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ model: name, context: ctxValue() }),
});
if (!resp.ok) throw new Error("(" + resp.status + ") " + (await resp.text()).slice(0, 200));
st = await resp.json();
for (;;) {
const secs = Math.round((Date.now() - started) / 1000);
let text = (stages[st.state] || st.state) + " — " + fill(say.seconds, { n: secs });
if (st.state === "downloading" && st.total) text += " · " + fill(say.of, { done: mib(st.done), total: mib(st.total) });
label.textContent = text;
// Indeterminate (no value) unless there is a real number: a bar that
// sits at 0% for the engine start says it is stuck when it is not.
if (st.percent != null) bar.value = st.percent; else bar.removeAttribute("value");
if (st.state === "ready") return true;
// A replica that failed before the wake is still listed until the
// control plane places its replacement, so one sighting is not final.
failures = st.state === "failed" ? failures + 1 : 0;
if (st.state === "stopped" || failures >= 5) {
throw new Error(st.message || say.couldNotStart);
}
await new Promise((r) => setTimeout(r, 1500));
const r = await fetch("/chat/model-status?model=" + encodeURIComponent(name) +
"&context=" + encodeURIComponent(ctxValue()));
if (!r.ok) throw new Error("(" + r.status + ") " + (await r.text()).slice(0, 200));
st = await r.json();
}
} catch (err) {
out.textContent = String(err.message || err);
out.classList.add("failed");
return false;
}
}
// The turn in progress, so the button can stop it.
let busy = null;
// A line under a reply: how long it took and how fast it came.
function stats(out, tokens, t0, tFirst, tEnd, stopped, hw) {
const p = document.createElement("p");
p.className = "note chat-stats";
const parts = [];
// What answered: a model with replicas on a card and on CPUs is served
// by either, and the speed below means nothing without knowing which.
if (hw) parts.push(hw === "cpu" ? "CPU" : "GPU " + hw);
if (tokens) parts.push(fill(say.tokens, { n: tokens }));
if (tokens && tFirst && tEnd > tFirst) parts.push(fill(say.tokPerS, { n: fixed(tokens / ((tEnd - tFirst) / 1000), 1) }));
if (tFirst) parts.push(fill(say.firstToken, { n: fixed((tFirst - t0) / 1000, 2) }));
parts.push(fill(say.seconds, { n: fixed((tEnd - t0) / 1000, 1) }));
if (stopped) parts.push(say.stopped);
p.textContent = parts.join(" · ");
out.after(p);
}
form.onsubmit = async (e) => {
e.preventDefault();
// A second press while a reply streams is Stop.
if (busy) { busy.abort(); return; }
const text = input.value.trim();
if (!text) return;
input.value = "";
bubble("you").textContent = text;
messages.push({ role: "user", content: text });
const out = bubble("assistant");
let reply = "";
let finish = "";
let tokens = 0, pieces = 0, stopped = false;
const opt = model.selectedOptions[0];
busy = new AbortController();
send.textContent = say.stop;
send.classList.add("stop");
const t0 = performance.now();
let tFirst = 0;
try {
if (opt && opt.dataset.ondemand === "1") {
if (!(await load(model.value, out))) { messages.pop(); return; }
// Up now: later messages go straight through.
opt.dataset.ondemand = "0";
opt.textContent = opt.value;
showSimNote();
out.textContent = "";
}
const system = sys.value.trim();
const post = () => fetch("/chat/send", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ model: model.value, context: ctxValue(), temperature: +temp.value,
knowledge: (document.getElementById("knowledge") || {}).value || "",
messages: (system ? [{ role: "system", content: system }] : []).concat(messages) }),
signal: busy.signal,
});
let resp = await post();
// Stopped since the page was drawn, or scaled away by idleness: load it
// again and retry once, rather than telling the person to.
if (resp.status === 503 && resp.headers.get("Retry-After")) {
if (!(await load(model.value, out))) { messages.pop(); return; }
out.textContent = "";
resp = await post();
}
if (!resp.ok) {
const text = await resp.text();
let shown = "(" + resp.status + ") " + text.slice(0, 400);
// Refused by a guardrail: said as what it is, not as a failure.
try {
const e = JSON.parse(text).error;
if (e && e.code === "content_policy_violation") {
const policy = resp.headers.get("x-llmgw-guard-policy");
shown = fill(say.refused, { policy: policy ? " " + policy : "", message: e.message });
} else if (e && e.code === "no_store") {
shown = e.message;
}
} catch (_) { /* not JSON: shown as it came */ }
out.textContent = shown;
out.classList.add("failed");
messages.pop();
return;
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
// Server-sent events arrive in whatever chunks the network felt
// like; only whole lines can be parsed.
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") continue;
try {
const chunk = JSON.parse(payload);
const piece = chunk.choices?.[0]?.delta?.content;
if (piece) {
if (!tFirst) tFirst = performance.now();
pieces++;
reply += piece; out.textContent = reply; log.scrollTop = log.scrollHeight;
}
// Why the model stopped. Without this a reply that hit the
// token cap is indistinguishable from one that finished, and
// the only clue is that it ended mid-word.
const why = chunk.choices?.[0]?.finish_reason;
if (why) finish = why;
// The engine's own count, on the last chunk.
if (chunk.usage && chunk.usage.completion_tokens) tokens = chunk.usage.completion_tokens;
} catch (_) { /* a partial frame; the next read completes it */ }
}
}
} catch (err) {
if (err.name !== "AbortError") throw err;
stopped = true;
}
if (reply) {
messages.push({ role: "assistant", content: reply });
// A reply with fenced code gets a download. The button carries the
// reply text; the server decides what the files are called, because
// those names come from the model and sanitising them is not a job
// for string handling in a page.
if (/^\s*(```|~~~)/m.test(reply)) {
const dl = document.createElement("button");
dl.type = "button";
dl.className = "link download";
dl.textContent = say.downloadZip;
dl.onclick = () => downloadCode(reply, dl);
out.after(dl);
}
if (finish === "content_filter") {
const note = document.createElement("p");
note.className = "note truncated";
note.textContent = fill(say.stoppedByPolicy, { policy: policySuffix(resp) });
out.after(note);
}
if (finish === "length") {
// Appended as a sibling, never into the reply: out holds model text
// placed with textContent, and this is ours.
const note = document.createElement("p");
note.className = "note truncated";
note.textContent = say.cutOff;
out.after(note);
}
// Which passages of the documents it was given, numbered as the
// reply cites them. Ours, so a sibling placed with textContent.
const src = resp.headers.get("x-llmgw-sources");
if (src) {
const note = document.createElement("p");
note.className = "note sources";
note.textContent = say.sources + " " + src.split(",").map((s, i) =>
"[" + (i + 1) + "] " + decodeURIComponent(s.split("#")[0])).join(" ");
out.after(note);
}
stats(out, tokens || pieces, t0, tFirst, performance.now(), stopped, resp.headers.get("x-llmgw-hardware"));
// Once, at the end. Re-rendering every chunk would try to typeset half
// an expression on each token and flicker its way through the reply.
await renderReply(out, reply);
renderMath(out);
}
else if (finish === "content_filter") {
out.textContent = fill(say.stoppedByPolicyEmpty, { policy: policySuffix(resp) });
out.classList.add("failed"); messages.pop();
}
else if (stopped) { out.textContent = say.stoppedEmpty; out.classList.add("failed"); messages.pop(); }
else { out.textContent = say.noContent; out.classList.add("failed"); }
} catch (err) {
if (err.name === "AbortError") {
out.textContent = say.stoppedEmpty;
messages.pop();
} else {
out.textContent = "(" + err + ")";
}
out.classList.add("failed");
} finally {
busy = null;
send.textContent = say.send;
send.classList.remove("stop");
save();
input.focus();
}
};
// A conversation kept from before, put back as it was: the person's turns
// as text, the model's rendered again by the server, as when they came.
(async () => {
const kept = load_();
if (!kept) return;
if (kept.system) sys.value = kept.system;
if (kept.temperature != null) { temp.value = kept.temperature; tempOut.textContent = fixed(+temp.value, 1); }
// Open when there is a prompt in it, so it is not a hidden surprise.
document.getElementById("settings").open = !!kept.system;
if (kept.model && [...model.options].some((o) => o.value === kept.model)) { model.value = kept.model; showSimNote(); }
for (const m of kept.messages || []) {
if (m.role === "user") bubble("you").textContent = m.content;
else if (m.role === "assistant") {
const out = bubble("assistant");
out.textContent = m.content;
await renderReply(out, m.content);
renderMath(out);
}
messages.push(m);
}
})();
})();
</script>
</body></html>{{end}}