Files
llmgw/internal/web/templates/meshes.html
T
Ilker ManapandClaude Opus 5.5 f8d18efaef
CI / test (push) Successful in 5m35s
Turkish pages are Turkish to the end
The errors other packages give a page (store, catalog, mirror) are
shown in its language; a percentage is written as the language writes
it (%93 in Turkish); the footer's time in its date form; and a stale
form is refused with a sentence rather than "bad request".

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

179 lines
7.3 KiB
HTML

{{define "meshes"}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "meshes.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<script defer src="{{asset "pages.js"}}"></script>
<script type="module" src="{{asset "model-viewer.min.js"}}"></script>
<meta name="csrf" content="{{.CSRF}}">
</head><body><div class="wrap">
<header>
<h1>{{t "meshes.title"}}</h1>
<p class="sub">{{.User.Email}}</p>
{{template "nav" .Nav}}
</header>
{{if gt (len .Contexts) 1}}
<form method="get" action="/3d" 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 "meshes.resolution"}}
<select id="resolution" title="{{t "meshes.resolution_hint"}}">
<option value="">—</option><option>192</option><option>256</option><option>384</option><option>512</option>
</select></label>
<label class="inline-label">{{t "meshes.seed"}}
<input type="number" id="seed" min="0" step="1" class="images-num"></label>
</div>
<label class="meshes-picture">{{t "meshes.picture"}} <span class="opt">{{t "meshes.picture_hint"}}</span>
<input type="file" id="picture" accept="image/png,image/jpeg,image/webp"></label>
<img id="preview" class="meshes-preview" alt="" hidden>
<div class="chat-composer"><button type="submit" id="go">{{t "meshes.make"}}</button></div>
</form>
<p id="status" class="note" aria-live="polite"></p>
<div id="gallery" class="images-gallery"></div>
<p class="note">{{t "meshes.kept"}}</p>
{{else}}
<p class="empty">{{t "meshes.empty"}}</p>
<p class="note">{{th "meshes.empty_why"}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div>
<script>
const say = {
queued: {{t "meshes.js.queued"}},
queuedNone: {{t "meshes.js.queued_none"}},
running: {{t "meshes.js.running"}},
done: {{t "meshes.js.done"}},
failed: {{t "meshes.js.failed"}},
download: {{t "meshes.js.download"}},
license: {{t "meshes.js.license"}},
noPicture: {{t "meshes.js.no_picture"}},
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); }
// A 3D model is a job: asked for, then asked after until it is made. The
// mesh is shown from the gateway itself, which the page's policy allows.
(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"), preview = $("preview");
let picture = "";
const ctx = () => ($("context") ? $("context").value : "");
$("picture").addEventListener("change", () => {
const f = $("picture").files[0];
if (!f) return;
const rd = new FileReader();
rd.addEventListener("load", () => {
picture = rd.result;
preview.src = picture;
preview.hidden = false;
});
rd.readAsDataURL(f);
});
function describe(j) {
const p = j.progress || {};
if (j.status === "queued" || p.state === "queued" || !p.state) {
return p.ahead ? fill(say.queued, { ahead: p.ahead }) : say.queuedNone;
}
return fill(say.running, { node: p.node_class || "", percent: p.percent || 0 });
}
async function follow(id, started) {
const q = ctx() ? "?context=" + encodeURIComponent(ctx()) : "";
for (;;) {
const resp = await fetch("/3d/jobs/" + encodeURIComponent(id) + q);
const j = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error((j.error && j.error.message) || String(resp.status));
if (j.status === "completed") return j;
if (["failed", "cancelled", "expired"].includes(j.status)) {
throw new Error((j.error && j.error.message) || j.status);
}
status.textContent = describe(j);
await new Promise((r) => setTimeout(r, 2000));
}
}
function show(j, took) {
const q = ctx() ? "?context=" + encodeURIComponent(ctx()) : "";
const src = "/3d/jobs/" + encodeURIComponent(j.id) + "/file" + q;
const fig = document.createElement("figure");
const mv = document.createElement("model-viewer");
mv.setAttribute("src", src);
mv.setAttribute("camera-controls", "");
mv.setAttribute("auto-rotate", "");
mv.setAttribute("shadow-intensity", "0.6");
mv.className = "meshes-viewer";
const cap = document.createElement("figcaption");
const line = document.createElement("span");
line.textContent = j.model + " · " + j.id + " · " + fill(say.done, { s: fixed(took, 0) });
const dl = document.createElement("a");
dl.href = src;
dl.download = j.id + ".glb";
dl.className = "link";
dl.textContent = say.download;
cap.append(line, " ", dl);
fig.append(mv, cap);
if (j.license) {
const lic = document.createElement("p");
lic.className = "images-prompt";
lic.textContent = say.license + ": " + j.license.name + (j.license.notice ? " — " + j.license.notice : "");
fig.append(lic);
}
gallery.prepend(fig);
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (go.disabled) return;
if (!picture) { status.textContent = say.noPicture; return; }
const body = { model: $("model").value, image: picture, context: ctx() };
if ($("seed").value !== "") body.seed = Number($("seed").value);
if ($("resolution").value !== "") body.resolution = Number($("resolution").value);
go.disabled = true;
status.classList.remove("error");
status.textContent = say.queuedNone;
const start = performance.now();
try {
const resp = await fetch("/3d/generate", {
method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify(body),
});
const text = await resp.text();
let j = null;
try { j = JSON.parse(text); } catch (_) {}
if (!resp.ok || !j || !j.id) {
throw new Error(String((j && j.error && j.error.message) || text.trim() || resp.status));
}
const done = await follow(j.id, start);
const took = (performance.now() - start) / 1000;
status.textContent = fill(say.done, { s: fixed(took, 0) });
show(done, took);
} catch (err) {
status.textContent = fill(say.failed, { why: err.message });
status.classList.add("error");
} finally {
go.disabled = false;
}
});
})();
</script>
</body></html>{{end}}