@@ -2804,6 +2901,133 @@ recsState.keyNames = keyNames || {};
$("#ct-imgs").style.display = "none";
$("#ct-msg").focus();
}
+ /* ---------- image tab ---------- */
+ // Image generation lives in its own page rather than as an option in the
+ // chat model dropdown.
+ //
+ // The dropdown used to list image models beside chat ones because
+ // /api/status flattened the catalog to []string and dropped the kind.
+ // Picking one sent the request to /api/chat, which cannot serve it. A
+ // second tab makes the distinction structural: the only models offered
+ // here are kind=image, and the endpoint called is the image one.
+ let imgBusy = false;
+ function imgModels() {
+ const out = [];
+ document.querySelectorAll("#it-model option").forEach((o) => {
+ if (o.value) out.push(o.value);
+ });
+ return out;
+ }
+ async function renderImage() {
+ const s = await api("/api/status");
+ const opts = [];
+ if (Array.isArray(s.sources) && s.sources.length) {
+ s.sources.forEach((src) => {
+ const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
+ ? src.model_kinds
+ : (src.models || []).map((m) => ({ id: m, kind: "chat" }));
+ kinds.forEach((m) => {
+ const id = typeof m === "string" ? m : m.id;
+ const kind = typeof m === "string" ? "chat" : m.kind;
+ if (kind !== "image") return;
+ opts.push(
+ `
`,
+ );
+ });
+ });
+ }
+ const body = opts.length
+ ? ""
+ : `
${esc(t("imgNoModels"))}
`;
+ $("#tab-image").innerHTML = `
+
+
+
+
+
M
+
${esc(t("imgWelcome"))}
+
${esc(t("imgSlow"))}
+
+
+
+
+
`;
+ }
+ function clearImages() {
+ $("#it-grid").innerHTML = `
+
M
+
${esc(t("imgWelcome"))}
+
${esc(t("imgSlow"))}
+
`;
+ }
+ // Image generation is slow — measured 16s to 2min on the one upstream
+ // tested — so the button reports elapsed time instead of looking hung.
+ async function sendImage() {
+ if (imgBusy) return;
+ const prompt = ($("#it-prompt").value || "").trim();
+ const model = $("#it-model").value;
+ if (!prompt) {
+ toast(t("imgNeedPrompt"));
+ return;
+ }
+ if (!model) {
+ toast(t("imgNoModels"));
+ return;
+ }
+ const n = Math.max(1, Math.min(4, parseInt($("#it-n").value, 10) || 1));
+ const size = ($("#it-size").value || "").trim();
+ imgBusy = true;
+ const btn = $("#it-send");
+ btn.disabled = true;
+ const t0 = Date.now();
+ const tick = setInterval(() => {
+ btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000)));
+ }, 1000);
+ try {
+ const r = await api("/v1/images/generations", {
+ method: "POST",
+ body: size ? { model: model, prompt: prompt, n: n, size: size } : { model: model, prompt: prompt, n: n },
+ });
+ const empty = $("#it-empty");
+ if (empty) empty.remove();
+ const grid = $("#it-grid");
+ const items = Array.isArray(r.data) ? r.data : [];
+ items.forEach((it) => {
+ const src = it.url || (it.b64_json ? "data:image/png;base64," + it.b64_json : "");
+ if (!src) return;
+ grid.insertAdjacentHTML(
+ "beforeend",
+ `
})
+
${esc(it.revised_prompt || model)}
`,
+ );
+ });
+ if (!items.length) toast(t("imgNoData"));
+ } catch (e) {
+ toast(t("imgFailed") + (e && e.message ? ": " + e.message : ""));
+ } finally {
+ clearInterval(tick);
+ imgBusy = false;
+ btn.disabled = false;
+ btn.textContent = t("imgGenerate");
+ }
+ }
+
function updateGrow() {
const ta = $("#ct-msg");
if (!ta) return;
@@ -3161,7 +3385,8 @@ recsState.keyNames = keyNames || {};
${adSel}
@@ -5814,6 +6040,7 @@ toast(t("kAutoChainGlobal"));
const done = () => remountPluginElements();
if (tab === "status") return renderStatus().then(done, done);
if (tab === "chat") return renderChat().then(done, done);
+ if (tab === "image") return renderImage().then(done, done);
if (tab === "keys") return renderKeys().then(done, done);
if (tab === "sort") return renderSort().then(done, done);
if (tab === "sources") return renderSources().then(done, done);
diff --git a/internal/gateway/ui_image_page_test.go b/internal/gateway/ui_image_page_test.go
new file mode 100644
index 0000000..a1b6bfe
--- /dev/null
+++ b/internal/gateway/ui_image_page_test.go
@@ -0,0 +1,195 @@
+package gateway
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "llmsproxy/internal/provider"
+)
+
+// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
+// started this: the chat page listed image models beside chat ones, and picking
+// one posted to /api/chat, which cannot serve it.
+//
+// The contract is asserted on the HTML source rather than by running a browser,
+// because the failure this must catch is a source-level one — an