mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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 端点却漏掉
了按钮。这条全文件扫描当场抓到了第二个同源缺陷(自动探测按钮)。
(cherry picked from commit 2fdb82b119)
This commit is contained in:
@ -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
|
||||
|
||||
109
internal/gateway/ui_image_wire_test.go
Normal file
109
internal/gateway/ui_image_wire_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user