Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
179 lines
7.4 KiB
HTML
179 lines
7.4 KiB
HTML
{{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}}
|