From 45954fe2c7a72c1ebd9890e9d6d78a58fefe28f8 Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Fri, 18 Sep 2026 11:15:31 +0800 Subject: [PATCH 1/2] feat(webui): name 5xx failures as server errors - add an `errors.server` message and use it for 5xx responses in the toast bridge and the error boundary, instead of "request failed" or the network hint which misattribute a server-side fault - format the toast as `status: headline` so the code leads and the phrase says whose fault it is --- webui/frontend/app/lib/locales/en.json | 1 + webui/frontend/app/lib/locales/zh.json | 1 + webui/frontend/app/root.tsx | 17 ++++++++++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/webui/frontend/app/lib/locales/en.json b/webui/frontend/app/lib/locales/en.json index f7b0a3136..a056dfd91 100644 --- a/webui/frontend/app/lib/locales/en.json +++ b/webui/frontend/app/lib/locales/en.json @@ -523,6 +523,7 @@ }, "errors": { "requestFailed": "Request failed", + "server": "Server error — please try again later", "network": "Network error — please check your connection", "unexpected": "Something went wrong", "backHome": "Back to home" diff --git a/webui/frontend/app/lib/locales/zh.json b/webui/frontend/app/lib/locales/zh.json index 6be3c55ea..e349dc7d1 100644 --- a/webui/frontend/app/lib/locales/zh.json +++ b/webui/frontend/app/lib/locales/zh.json @@ -523,6 +523,7 @@ }, "errors": { "requestFailed": "请求失败", + "server": "服务器出错了,请稍后重试", "network": "网络错误,请检查您的网络连接", "unexpected": "出错了", "backHome": "回到首页" diff --git a/webui/frontend/app/root.tsx b/webui/frontend/app/root.tsx index e26b9b913..32472c69d 100644 --- a/webui/frontend/app/root.tsx +++ b/webui/frontend/app/root.tsx @@ -289,11 +289,19 @@ function ApiErrorBridge() { err.code === err.status && err.statusText ? `${err.status} ${err.statusText}` : String(err.code) + // A 5xx (or a gateway answering for an unreachable backend, e.g. 502/504) + // is a server-side fault, so "request failed" — which reads as the caller's + // mistake — understates it; name it a server error instead. `code` holds + // the real number for a body-declared rejection carrying a 2xx status. + const headline = + err.code >= 500 ? t.errors.server : t.errors.requestFailed + // Status first, explanation second (`502 Bad Gateway: server error…`): the + // number locates the failure at a glance, the phrase says whose fault it is. const text = msg ? msg : err.status === 0 ? t.errors.network - : `${t.errors.requestFailed}: ${detail}` + : `${detail}: ${headline}` message.error(text) }) return () => registerApiErrorReporter(null) @@ -327,9 +335,12 @@ export function ErrorBoundary() { ? error.message : String(error ?? '') // Some failures carry no words at all (backend never answered, or an empty - // gateway body), which left the headline over an empty paragraph. + // gateway body), which left the headline over an empty paragraph. A 5xx is a + // server-side fault, so it falls back to the server error, not "check your + // connection" (the user's network is not the problem). const description = - reported || (status === 502 ? t.errors.network : t.errors.requestFailed) + reported || + (status && status >= 500 ? t.errors.server : t.errors.requestFailed) return ( Date: Fri, 18 Sep 2026 11:27:44 +0800 Subject: [PATCH 2/2] feat(webui): read chat-stream failures with the shared helper - extract the failure-to-text logic into `describeFailure` in the api layer and call it from the toast bridge instead of inlining it - throw an `ApiError` from the chat-stream guard so a failed turn carries the status and any backend message - describe failed chat turns through the same helper so they read like a failed REST call --- .../app/components/chat/ChatPanel.tsx | 18 ++++++-- webui/frontend/app/lib/agentProvider.ts | 13 +++++- webui/frontend/app/lib/api.ts | 20 +++++++++ webui/frontend/app/root.tsx | 45 ++++++------------- 4 files changed, 59 insertions(+), 37 deletions(-) diff --git a/webui/frontend/app/components/chat/ChatPanel.tsx b/webui/frontend/app/components/chat/ChatPanel.tsx index 3e8932ebf..370b4d6c0 100644 --- a/webui/frontend/app/components/chat/ChatPanel.tsx +++ b/webui/frontend/app/components/chat/ChatPanel.tsx @@ -17,7 +17,7 @@ import { type HistoryMessage, type MessageSegment } from '~/lib/agentProvider' -import { api } from '~/lib/api' +import { ApiError, api, describeFailure } from '~/lib/api' import { dispatchUrlChange, dispatchSessionDone, @@ -187,6 +187,18 @@ export function ChatPanel({ // Aliased: `message` is shadowed all over this file by per-message callback // params, so the toast handle gets an unambiguous name. const { message: toast } = App.useApp() + // A failed chat turn reads the same as a failed REST call: its rejection + // carries an ApiError (status + any backend message), so `describeFailure` + // yields the status-first, server-vs-client line; a non-HTTP error (a bare + // network blip) keeps its own message. + const failureText = (error: unknown): string => + error instanceof ApiError + ? describeFailure(error, { + server: t.errors.server, + requestFailed: t.errors.requestFailed, + network: t.errors.network + }) + : `${t.chat.requestFailed}: ${(error as Error)?.message ?? ''}` const hydrated = useHydrated() const listRef = useRef(null) const [projectOverride, setProjectOverride] = useState(null) @@ -322,7 +334,7 @@ export function ChatPanel({ } return { role: 'assistant', - content: `${t.chat.requestFailed}: ${error?.message ?? ''}` + content: failureText(error) } } }) @@ -706,7 +718,7 @@ export function ChatPanel({ // toasting those would be pure noise; a rejection is the one case where // the placeholder vanishing is otherwise unexplained. if ((e as Error)?.name === CHAT_STREAM_ERROR) { - toast.error(`${t.chat.requestFailed}: ${(e as Error).message}`) + toast.error(failureText(e)) } } finally { // Only clear attaching if THIS stream's ctrl is still the active one. diff --git a/webui/frontend/app/lib/agentProvider.ts b/webui/frontend/app/lib/agentProvider.ts index 17c62f1f7..c4831cd06 100644 --- a/webui/frontend/app/lib/agentProvider.ts +++ b/webui/frontend/app/lib/agentProvider.ts @@ -3,7 +3,7 @@ import { type TransformMessage, type XRequestOptions, } from "@ant-design/x-sdk"; -import { readFailure } from "~/lib/api"; +import { ApiError, readFailure } from "~/lib/api"; import { dispatchImageDelivery } from "~/lib/imageDelivery"; import { dispatchWorkspaceChanged } from "~/lib/events"; @@ -43,7 +43,16 @@ export async function assertChatStream(response: Response): Promise { body = undefined; } const failure = readFailure(body); - const err = new Error(failure?.message || `HTTP ${response.status}`); + // Throw the same ApiError the REST client raises, so the failure reads + // identically wherever it surfaces (via `describeFailure`): a bare status + // when the body carried no message, the message when it did. `name` marks it + // as a chat-stream rejection so callers can tell it from an abort. + const err = new ApiError( + failure?.message ?? "", + response.status, + failure?.code ?? response.status, + response.statusText + ); err.name = CHAT_STREAM_ERROR; throw err; } diff --git a/webui/frontend/app/lib/api.ts b/webui/frontend/app/lib/api.ts index 3b1b4c16a..60f91f846 100644 --- a/webui/frontend/app/lib/api.ts +++ b/webui/frontend/app/lib/api.ts @@ -80,6 +80,26 @@ export class ApiError extends Error { } } +/** User-facing text for a failed request: the backend's own message when it + * sent one, otherwise the HTTP status first (`502 Bad Gateway`) and a + * server-vs-client headline second. `code` — not `status` — picks both the + * headline and the bare-number detail, since a gateway rejection can arrive + * with a 2xx status while `code` holds the real one. Shared by the REST toast + * bridge and the chat-stream paths so a failure reads the same everywhere. */ +export function describeFailure( + err: ApiError, + s: { server: string; requestFailed: string; network: string } +): string { + if (err.message) return err.message + if (err.status === 0) return s.network + const detail = + err.code === err.status && err.statusText + ? `${err.status} ${err.statusText}` + : String(err.code) + const headline = err.code >= 500 ? s.server : s.requestFailed + return `${detail}: ${headline}` +} + // Global error reporter, registered once in the browser by the app shell (see // root.tsx). Lets the API layer surface a single, consistent toast for every // failed request without each call site repeating `message.error(...)`. Stays diff --git a/webui/frontend/app/root.tsx b/webui/frontend/app/root.tsx index 32472c69d..90b22f764 100644 --- a/webui/frontend/app/root.tsx +++ b/webui/frontend/app/root.tsx @@ -19,7 +19,7 @@ import './app.css' import { NProgressHandler } from '~/components/common/NProgressHandler' import { renderAntdEmpty } from '~/components/common/EmptyState' import { ErrorState } from '~/components/common/ErrorState' -import { api, ApiError, orThrow, registerApiErrorReporter } from '~/lib/api' +import { api, ApiError, describeFailure, orThrow, registerApiErrorReporter } from '~/lib/api' import { getAntdCssHref } from '~/lib/antdStyle.server' import { getDesignTokenStyleContent } from '~/lib/designTokens' import { SERVER_HOSTED_MODE } from '~/lib/env' @@ -272,37 +272,18 @@ function ApiErrorBridge() { const { message } = AntdApp.useApp() const { t } = useT() useEffect(() => { - registerApiErrorReporter((msg: string, err: ApiError) => { - // No message means the failure was not reported by our backend at all — - // something in FRONT of it answered (a proxy/gateway 502, an upstream - // 504) with a body carrying no envelope. Naming the number keeps a burst - // of such toasts distinguishable and reportable instead of an - // indistinguishable wall of "Request failed". - // `code`, not `status`: the two are equal for a transport failure, but a - // rejection the body declares itself (readFailure) can arrive with a 2xx - // status, and only `code` then holds the real one. - // The reason phrase is appended when there is one, since it is the only - // words such a failure carries — absent over HTTP/2, hence the bare-code - // fallback. It pairs with `status` ONLY: for the 200-OK-with-code-400 case - // above, "400 OK" would describe neither half truthfully. - const detail = - err.code === err.status && err.statusText - ? `${err.status} ${err.statusText}` - : String(err.code) - // A 5xx (or a gateway answering for an unreachable backend, e.g. 502/504) - // is a server-side fault, so "request failed" — which reads as the caller's - // mistake — understates it; name it a server error instead. `code` holds - // the real number for a body-declared rejection carrying a 2xx status. - const headline = - err.code >= 500 ? t.errors.server : t.errors.requestFailed - // Status first, explanation second (`502 Bad Gateway: server error…`): the - // number locates the failure at a glance, the phrase says whose fault it is. - const text = msg - ? msg - : err.status === 0 - ? t.errors.network - : `${detail}: ${headline}` - message.error(text) + registerApiErrorReporter((_msg: string, err: ApiError) => { + // One toast for every failed request. `describeFailure` composes the text: + // the backend's own message when it sent one, otherwise the status first + // (`502 Bad Gateway`) and a server-vs-client headline second. The chat + // stream reuses the same helper so a failure reads identically there. + message.error( + describeFailure(err, { + server: t.errors.server, + requestFailed: t.errors.requestFailed, + network: t.errors.network + }) + ) }) return () => registerApiErrorReporter(null) }, [message, t])