Files
llmgw/internal/web/templates/images.html
T
2026-10-09 23:04:52 +02:00

179 lines
7.4 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "images"}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "images.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<script defer src="/_static/pages.js"></script>
<meta name="csrf" content="{{.CSRF}}">
</head><body><div class="wrap">
<header>
<h1>{{t "images.title"}}</h1>
<p class="sub">{{.User.Email}}</p>
{{template "nav" .Nav}}
</header>
{{if gt (len .Contexts) 1}}
<form method="get" action="/images" 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>
<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}}
<form id="make" class="images-form">
<div class="chat-bar">
<label class="inline-label">{{t "chat.model"}}
<select id="model">{{range .Models}}<option value="{{.Name}}">{{.Name}}{{with .Runs}} · {{.}}{{end}}</option>{{end}}</select>
</label>
<label class="inline-label">{{t "images.size"}}
<select id="size">
<option value="1024x1024">1024 × 1024</option>
<option value="1024x768">1024 × 768</option>
<option value="768x1024">768 × 1024</option>
<option value="1280x720">1280 × 720</option>
<option value="768x768">768 × 768</option>
<option value="512x512">512 × 512</option>
</select></label>
<label class="inline-label">{{t "images.count"}}
<select id="n"><option>1</option><option>2</option><option>3</option><option>4</option></select></label>
</div>
<details class="chat-settings">
<summary>{{t "chat.settings"}}</summary>
<label>{{t "images.negative"}} <span class="opt">{{t "images.negative_hint"}}</span>
<textarea id="negative" rows="2"></textarea></label>
<label>{{t "images.seed"}} <span class="opt">{{t "images.seed_hint"}}</span>
<input type="number" id="seed" min="0" step="1" class="images-num"></label>
<label>{{t "images.steps"}} <span class="opt">{{t "images.steps_hint"}}</span>
<input type="number" id="steps" min="1" max="100" step="1" class="images-num"></label>
</details>
<div class="chat-composer">
<textarea id="prompt" rows="3" placeholder="{{t "images.prompt_placeholder"}}" required></textarea>
<button type="submit" id="go">{{t "images.make"}}</button>
</div>
</form>
<p id="status" class="note" aria-live="polite"></p>
<div id="gallery" class="images-gallery"></div>
<p class="note">{{t "images.kept"}}</p>
{{else}}
<p class="empty">{{t "images.empty"}}</p>
<p class="note">{{th "images.empty_why"}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
</div>
<script>
const say = {
making: {{t "images.js.making"}},
made: {{t "images.js.made"}},
failed: {{t "images.js.failed"}},
download: {{t "images.js.download"}},
reuseSeed: {{t "images.js.reuse_seed"}},
seed: {{t "images.js.seed"}},
make: {{t "images.make"}},
decimal: {{t "_meta.decimal"}},
};
function fill(s, vals) { return s.replace(/\{(\w+)\}/g, (m, k) => (k in vals ? String(vals[k]) : m)); }
function fixed(n, places) { return n.toFixed(places).replace(".", say.decimal); }
// One request at a time, and nothing kept: a picture is a few megabytes,
// and this page is for trying a model, not for keeping what it made.
(function () {
const form = document.getElementById("make");
if (!form) return;
const csrf = document.querySelector('meta[name="csrf"]').content;
const $ = (id) => document.getElementById(id);
const status = $("status"), gallery = $("gallery"), go = $("go");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const prompt = $("prompt").value.trim();
if (!prompt || go.disabled) return;
const body = {
model: $("model").value, prompt, size: $("size").value, n: Number($("n").value),
context: $("context") ? $("context").value : "",
};
const neg = $("negative").value.trim();
if (neg) body.negative_prompt = neg;
if ($("seed").value !== "") body.seed = Number($("seed").value);
if ($("steps").value !== "") body.steps = Number($("steps").value);
go.disabled = true;
status.classList.remove("error");
const start = performance.now();
const tick = () => { status.textContent = fill(say.making, { s: fixed((performance.now() - start) / 1000, 0) }); };
tick();
const timer = setInterval(tick, 1000);
try {
const resp = await fetch("/images/generate", {
method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify(body),
});
const text = await resp.text();
let data = null;
try { data = JSON.parse(text); } catch (_) {}
if (!resp.ok || !data || !data.data) {
const why = (data && data.error && data.error.message) || text.trim() || resp.status;
throw new Error(String(why));
}
const seed = resp.headers.get("x-llmgw-seed") || "";
const took = (performance.now() - start) / 1000;
status.textContent = fill(say.made, { n: data.data.length, s: fixed(took, 1) });
show(data.data, seed, body);
} catch (err) {
status.textContent = fill(say.failed, { why: err.message });
status.classList.add("error");
} finally {
clearInterval(timer);
go.disabled = false;
}
});
// Newest first, and a batch in its own order: the last of it is
// prepended first.
function show(items, seed, body) {
items.map((it, i) => [it, i]).reverse().forEach(([it, i]) => {
const src = "data:image/png;base64," + it.b64_json;
const fig = document.createElement("figure");
const img = document.createElement("img");
img.src = src;
img.alt = body.prompt;
img.loading = "lazy";
const cap = document.createElement("figcaption");
const line = document.createElement("span");
line.textContent = body.model + " · " + body.size.replace("x", " × ") +
(seed ? " · " + fill(say.seed, { seed }) + (items.length > 1 ? " #" + (i + 1) : "") : "");
const dl = document.createElement("a");
dl.href = src;
dl.download = (body.model + "-" + (seed || "x") + "-" + (i + 1) + ".png").replace(/[^\w.-]+/g, "_");
dl.textContent = say.download;
dl.className = "link";
cap.append(line, " ", dl);
if (seed) {
const again = document.createElement("button");
again.type = "button";
again.className = "link";
again.textContent = say.reuseSeed;
again.addEventListener("click", () => {
$("seed").value = seed;
form.querySelector("details").open = true;
$("prompt").focus();
});
cap.append(" ", again);
}
const p = document.createElement("p");
p.className = "images-prompt";
p.textContent = body.prompt;
fig.append(img, cap, p);
gallery.prepend(fig);
});
}
$("prompt").addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); form.requestSubmit(); }
});
})();
</script>
</body></html>{{end}}