CI / test (push) Successful in 11m15s
Five pages named /_static/pages.js outright, cached for a day, so a browser kept the old script after an upgrade — the one holding refreshes off while a form is filled in among them. They take it through asset, as the rest do, whose address changes with its contents. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
566 lines
25 KiB
HTML
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="{{asset "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}}
|