fix(ui): 生图与自动探测按钮把对象塞给了 fetch

`api()` 是 `(p, o) => fetch(p, o)`,第二个参数是原生 fetch options,
所以 `body` 必须是字符串。两处都传了对象字面量:

- 生图页 `body: { model, prompt, n, size }`
- 源编辑框自动探测 `body: { base_url, api_key }`

fetch 把对象转成 `"[object Object]"`,网关 json.Unmarshal 失败返回
400,请求根本没到上游。生图页的现象是 lat_ms: 0。

两处都改成 JSON.stringify + 显式 Content-Type。

判据在源码层扫描全文件的每个 `method: "POST"`,而不是断言 DOM ——
wire format 在 DOM 里不可见,这也正是为什么之前测过 curl 端点却漏掉
了按钮。这条全文件扫描当场抓到了第二个同源缺陷(自动探测按钮)。
This commit is contained in:
JianFeeeee
2026-10-06 23:43:04 +08:00
parent c96e6051ab
commit 2fdb82b119
2 changed files with 134 additions and 2 deletions

View File

@ -3000,9 +3000,25 @@ async function renderChat() {
btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000)));
}, 1000);
try {
// api() is a thin wrapper over fetch(p, o) — o is the NATIVE
// fetch options object, so body must be a STRING.
//
// Passing the object directly made fetch coerce it to the literal
// text "[object Object]", the gateway's json.Unmarshal rejected it,
// and the page showed a bare "400 Bad Request" with no image and no
// usable message. It cost a round of debugging because the DOM, the
// model list and the button state were all correct — only the wire
// format was wrong, which is invisible until you capture the request.
//
// JSON.stringify + an explicit Content-Type is what every other
// POST in this file does; the missing header plus the object body
// meant the server saw Content-Type: text/plain and a string body.
const payload = { model: model, prompt: prompt, n: n };
if (size) payload.size = size;
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 },
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const empty = $("#it-empty");
if (empty) empty.remove();
@ -3483,7 +3499,14 @@ async function renderChat() {
try {
const r = await api("/api/sources/discover", {
method: "POST",
body: { base_url: baseUrl, api_key: apiKey },
// api() hands its second argument straight to fetch, so `body`
// must be a STRING. Passing the object made fetch send the literal
// text "[object Object]" and the gateway answered
// 400 invalid character 'o' — the Auto-discover button looked
// dead while curl against the same endpoint worked, because the
// curl test exercised the ENDPOINT and not this BUTTON.
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ base_url: baseUrl, api_key: apiKey }),
});
// The server normalized the URL form. Showing the operator exactly
// what will be saved is the whole point of probing server-side

View File

@ -0,0 +1,109 @@
package gateway
import (
"strings"
"testing"
)
// TestUI_ImageRequestSendsJSONBody pins the bug that made the image page
// unusable in production while every DOM assertion still passed.
//
// The page called api(path, {method, body: {...}}). api() is
// `(p, o) => fetch(p, o)` — o is the NATIVE fetch options object, so an object
// in `body` is coerced by fetch into the literal text "[object Object]". The
// gateway's json.Unmarshal rejected it, the response was a bare 400 with
// lat_ms 0, and the page showed an error toast with no image and no usable
// message.
//
// What made this expensive: the pane rendered, the model list had all six
// models, the button enabled itself, the console was clean. Only the WIRE
// format was wrong, and nothing in the DOM shows a wire format.
func TestUI_ImageRequestSendsJSONBody(t *testing.T) {
body := sendImageSource(t)
if !strings.Contains(body, "JSON.stringify") {
t.Error("sendImage() does not JSON.stringify its body — fetch coerces " +
"the object to the text \"[object Object]\" and the gateway answers " +
"400 with lat_ms 0")
}
if !strings.Contains(body, `"Content-Type": "application/json"`) {
t.Error("sendImage() does not set Content-Type: application/json; the " +
"server sees text/plain and may refuse to parse the body")
}
// The literal defect, asserted so a later refactor reintroducing it fails
// here rather than in a browser.
if strings.Contains(body, "body: size ? {") || strings.Contains(body, "body: { model") {
t.Error("sendImage() still passes an object literal as the fetch body")
}
}
// TestUI_PostCallsSendJSONString guards every POST in the file, not just the
// image one. The image page happened to be the only place that used api() with
// an object body, so a check scoped to it would pass while another page had the
// same defect.
func TestUI_PostCallsSendJSONString(t *testing.T) {
ui := uiSource(t)
lines := strings.Split(ui, "\n")
var bad []string
for i, l := range lines {
if !strings.Contains(l, `method: "POST"`) {
continue
}
end := i + 8
if end > len(lines) {
end = len(lines)
}
window := strings.Join(lines[i:end], "\n")
if !strings.Contains(window, "body:") {
continue // no body at all: legitimate (a bare trigger)
}
if strings.Contains(window, "JSON.stringify") || strings.Contains(window, "FormData") {
continue
}
bad = append(bad, strings.TrimSpace(l))
}
if len(bad) > 0 {
t.Errorf("%d POST call(s) send a non-string body:", len(bad))
for _, b := range bad {
if len(b) > 100 {
b = b[:100]
}
t.Errorf(" %s", b)
}
}
}
// TestUI_ImagePageReportsServerErrors pins that a failed generation surfaces
// something actionable. The production bug produced a bare "400 Bad Request"
// toast; the requirement is that the gateway's own message reaches the page.
func TestUI_ImagePageReportsServerErrors(t *testing.T) {
body := sendImageSource(t)
// api() throws Error(j.error.message) on !r.ok, so appending e.message is
// what puts the explanation on screen.
if !strings.Contains(body, "e && e.message") {
t.Error("sendImage() does not surface the server's error message; a " +
"failure would show a bare status with no cause")
}
// And a success carrying no image must not look like a success.
if !strings.Contains(body, "imgNoData") {
t.Error("sendImage() does not warn when the upstream returns no image")
}
}
// sendImageSource returns the text of sendImage() only, so an assertion cannot
// be satisfied by a string that happens to appear elsewhere in the file.
func sendImageSource(t *testing.T) string {
t.Helper()
ui := uiSource(t)
i := strings.Index(ui, "async function sendImage(")
if i < 0 {
t.Fatal("sendImage() not found")
}
body := ui[i:]
if end := strings.Index(body[1:], "\n function "); end > 0 {
body = body[:end+1]
}
return body
}