Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions webui/frontend/app/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<MessageListHandle>(null)
const [projectOverride, setProjectOverride] = useState<string | null>(null)
Expand Down Expand Up @@ -322,7 +334,7 @@ export function ChatPanel({
}
return {
role: 'assistant',
content: `${t.chat.requestFailed}: ${error?.message ?? ''}`
content: failureText(error)
}
}
})
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions webui/frontend/app/lib/agentProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -43,7 +43,16 @@ export async function assertChatStream(response: Response): Promise<Response> {
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;
}
Expand Down
20 changes: 20 additions & 0 deletions webui/frontend/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions webui/frontend/app/lib/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions webui/frontend/app/lib/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@
},
"errors": {
"requestFailed": "请求失败",
"server": "服务器出错了,请稍后重试",
"network": "网络错误,请检查您的网络连接",
"unexpected": "出错了",
"backHome": "回到首页"
Expand Down
44 changes: 18 additions & 26 deletions webui/frontend/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -272,29 +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)
const text = msg
? msg
: err.status === 0
? t.errors.network
: `${t.errors.requestFailed}: ${detail}`
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])
Expand Down Expand Up @@ -327,9 +316,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 (
<ErrorState
Expand Down
Loading