From 82424564b64ce131da013c5c1d525970aa4b4f73 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 14:50:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E7=94=9F=E5=9B=BE=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E9=A1=B5=E9=9D=A2=20+=20=E5=9B=BE=E5=83=8F=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=86=8D=E6=B7=B7=E8=BF=9B=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ① 图像模型曾与对话模型不可区分 根因是结构性的,不是漏了个过滤: Provider.Models() → []string ← 只有 ID,Kind 被丢掉 SourceStatus.Models → []string ← 扁平列表 于是 /api/status 给前端的 34 个模型(29 对话 + 5 图像)长得一模一样。选了 gpt-image-2 就 POST 到 /api/chat —— 那个端点服务不了图像模型,必然失败, 而且报错完全不提「这是图像模型」。 修法:新增 ModelKinds []ModelKindInfo([{id,kind}]),纯增量、omitempty, 老 UI 不受影响;前端按 optgroup 分组,且这些 option 是 **disabled** 的。 分组还不够 —— 分组只是视觉分隔,operator 照样能选,选了照样失败。 disabled 的 option 会自解释(「请到生图页」),可选的只会失败。 ★ 写这段时踩了一次 String.replace 的坑:`imgOpts.join("").replace(...)` 只替换**第一个**匹配,于是 6 个图像模型里只有第 1 个被禁用。浏览器实测 逐条读 e.disabled 才抓到(siliconflow 禁用了、vela 的 5 个没有)。 判据只看 value 里有没有 "image" 的话,第一次跑就会永远报警——那是判据 自己错了,比没判据更糟。 ## ② 生图二级页面 侧栏新增 tab,与「对话」并列。网格展示 + 提示词输入 + 数量/尺寸, 调用 /v1/images/generations(不是 /api/chat)。 按钮显示已用秒数:实测该上游 15.7s~112.5s,波动 7.6 倍,不显示进度 看起来就像卡死了。 浏览器实测(几何测量,非仅 DOM 断言): tab-image 容器 986×523 visible img-grid 840×374 visible 提示词输入框 634×64 visible 生图页 6 个模型,全部 kind=image,全部可选 对话页 6 个图像模型,全部 disabled;97 个对话模型全部可选 ## ③ base_url 引导不足 文案直接给出截断位置和具体后果,而不是泛泛地说「填 URL」: 填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同: 若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。 不确定就用上方「自动探测模型」。 /v1/v1 不是假设:ImageURL 是 BaseURL + ImageEndpoint(),而图像默认路径 是**绝对**的 /v1/images/generations,chat 的默认是相对的 /chat/completions。 两者对「版本段归谁管」的假设相反。今天在 vela 上为此排查了两次。 ## 判据 新增 6 个契约测试: - 对话页必须读 model_kinds(不读就会退回扁平列表) - 禁用图像模型不能用 joined-HTML 的 String.replace(只改第一个) - 生图页存在且注册进 TABS(漏了则 pane 永不可见,症状是「页面空白」) - sendImage 必须打 /v1/images/generations - urlHint 必须同时提到 /v1 和 404 - TestImageEndpointComposesUnderRealConditions 用真实形态验证: 相对路径拼出 /v1/images/generations 得 200,默认路径拼出 /v1/v1/… 得 404 --- internal/gateway/ui/index.html | 277 ++++++++++++++++++++++--- internal/gateway/ui_image_page_test.go | 195 +++++++++++++++++ internal/provider/model_kind_test.go | 107 ++++++++++ internal/provider/provider.go | 19 ++ internal/provider/registry.go | 16 ++ 5 files changed, 589 insertions(+), 25 deletions(-) create mode 100644 internal/gateway/ui_image_page_test.go create mode 100644 internal/provider/model_kind_test.go diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index c65158b..6775462 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -366,6 +366,22 @@ /* ---- chat page ---- */ .tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)} + + .tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column; + height:calc(100vh - 190px)} + .img-wrap{flex:1;display:flex;flex-direction:column;min-height:0} + .img-grid{flex:1;overflow-y:auto;padding:12px 2px;display:grid; + grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;align-content:start} + .img-grid .chat-empty{grid-column:1/-1} + .img-cell{border:1px solid var(--line);border-radius:10px;overflow:hidden; + background:var(--bg2)} + .img-cell img{display:block;width:100%;height:auto;background:#fff} + .img-meta{padding:6px 8px;font-size:11px;color:var(--muted); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .img-composer{border-top:1px solid var(--line);padding:10px 2px 12px} + .img-composer .chat-box{display:flex;gap:8px;align-items:flex-end} + .img-composer textarea{flex:1;resize:vertical;min-height:52px} + .img-composer .empty{padding:8px 0} .chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:18px;box-shadow:var(--sh-lg); display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;backdrop-filter:blur(var(--glass)) saturate(1.5)} .chat-log{flex:1;overflow-y:auto;padding:20px 20px 8px;display:flex;flex-direction:column;gap:18px} @@ -573,6 +589,21 @@ />对话 + + + + ${body} + + + `; + } + 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", + `
generated +
${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}
- + +
${t("urlHint")}
@@ -3571,7 +3796,8 @@ recsState.keyNames = keyNames || {};
${adSel}
- + +
${t("urlHint")}
@@ -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