From 460454fb7cb12657ba5cbd7b3b8ef07381bf4732 Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Sat, 11 Apr 2026 09:38:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E5=90=8C=E6=84=8F=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E5=92=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=B8=A4=E6=9D=A1e2e=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/messages/message-group.tsx | 3 +- frontend/src/core/threads/hooks.ts | 104 +++++++++++++++--- frontend/tests/e2e/thread-error-toast.spec.ts | 79 +++++++++++++ 3 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 frontend/tests/e2e/thread-error-toast.spec.ts diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index 05ab6302..9b523933 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -13,6 +13,7 @@ import { WrenchIcon, } from "lucide-react"; import { useMemo, useState } from "react"; +import type { BundledLanguage } from "shiki"; import { ChainOfThought, @@ -222,7 +223,7 @@ function ToolCall({ expanded = false, }: { content: string; - language?: string; + language?: BundledLanguage; expanded?: boolean; }) => { const shouldCollapse = content.length > TOOL_CONTENT_COLLAPSE_THRESHOLD; diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index ce990f87..c2bb5abe 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -46,27 +46,74 @@ export type LegacyThreadStreamOptions = { useSubmitThread?: boolean; }; +const STREAM_ERROR_FALLBACK_MESSAGE = "Request failed."; +const STREAM_ERROR_TOAST_MESSAGE = "出现了某些错误。"; +const STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS = 2000; + +function readMessageCandidate(value: unknown): string | null { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (value instanceof Error && value.message.trim()) { + return value.message.trim(); + } + return null; +} + function getStreamErrorMessage(error: unknown): string { - if (typeof error === "string" && error.trim()) { - return error; + const directMessage = readMessageCandidate(error); + if (directMessage) { + return directMessage; } - if (error instanceof Error && error.message.trim()) { - return error.message; - } - if (typeof error === "object" && error !== null) { - const message = Reflect.get(error, "message"); - if (typeof message === "string" && message.trim()) { + + const visited = new Set(); + const queue: unknown[] = [error]; + const preferredKeys = ["message", "detail", "error"]; + + while (queue.length > 0) { + const current = queue.shift(); + if (current == null) { + continue; + } + + const message = readMessageCandidate(current); + if (message) { return message; } - const nestedError = Reflect.get(error, "error"); - if (nestedError instanceof Error && nestedError.message.trim()) { - return nestedError.message; + + if (typeof current !== "object") { + continue; } - if (typeof nestedError === "string" && nestedError.trim()) { - return nestedError; + + if (visited.has(current)) { + continue; + } + visited.add(current); + + for (const key of preferredKeys) { + const candidate = Reflect.get(current, key); + const parsed = readMessageCandidate(candidate); + if (parsed) { + return parsed; + } + if (candidate && typeof candidate === "object") { + queue.push(candidate); + } + } + + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + + for (const value of Object.values(current)) { + if (value && typeof value === "object") { + queue.push(value); + } } } - return "Request failed."; + + return STREAM_ERROR_FALLBACK_MESSAGE; } function normalizeThreadId( @@ -151,6 +198,9 @@ export function useThreadStream({ // and to allow access to the current thread id in onUpdateEvent const threadIdRef = useRef(threadId ?? null); const startedRef = useRef(false); + const lastErrorToastRef = useRef<{ message: string; timestamp: number } | null>( + null, + ); const listeners = useRef({ onStart, @@ -182,6 +232,23 @@ export function useThreadStream({ } }, []); + const showStreamErrorToast = useCallback((error: unknown) => { + const message = getStreamErrorMessage(error); + const now = Date.now(); + const lastToast = lastErrorToastRef.current; + if ( + lastToast && + lastToast.message === message && + now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS + ) { + return; + } + lastErrorToastRef.current = { message, timestamp: now }; + console.error("[useThreadStream] conversation stream error:", error); + console.error("[useThreadStream] parsed error message:", message); + toast.error(STREAM_ERROR_TOAST_MESSAGE); + }, []); + const handleStreamStart = useCallback( (_threadId: string) => { threadIdRef.current = _threadId; @@ -261,7 +328,7 @@ export function useThreadStream({ }, onError(error) { setOptimisticMessages([]); - toast.error(getStreamErrorMessage(error)); + showStreamErrorToast(error); }, onFinish(state) { listeners.current.onFinish?.(state.values); @@ -286,6 +353,13 @@ export function useThreadStream({ } }, [thread.messages.length, optimisticMessages.length]); + useEffect(() => { + if (!thread.error) { + return; + } + showStreamErrorToast(thread.error); + }, [thread.error, showStreamErrorToast]); + const sendMessage = useCallback( async ( threadId: string | undefined, diff --git a/frontend/tests/e2e/thread-error-toast.spec.ts b/frontend/tests/e2e/thread-error-toast.spec.ts new file mode 100644 index 00000000..5deef703 --- /dev/null +++ b/frontend/tests/e2e/thread-error-toast.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "@playwright/test"; + +import { + PRIMARY_THREAD_ID, + openChat, + reuseThreadChatEntry, + skipIfMissingThread, + waitForMessageListReady, +} from "./support/chat-helpers"; + +test.use({ + video: "on", +}); + +test.describe("聊天工作台 / 错误提示", () => { + test("DF-ERR-001 对话流失败时显示错误 toast", async ({ + page, + }, testInfo) => { + skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID"); + + await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!)); + await waitForMessageListReady(page, { requireMessages: false }); + + await page.route("**/*", async (route) => { + const request = route.request(); + if (request.method() === "POST" && /\/runs\b/.test(request.url())) { + await route.abort("failed"); + return; + } + await route.continue(); + }); + + const textarea = page.locator("textarea[name='message']"); + const submit = page.locator("button[aria-label='Submit']"); + await textarea.fill("触发错误 toast 测试"); + await submit.click({ force: true }); + + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "出现了某些错误。" }) + .first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("DF-ERR-002 相同错误短时间不重复弹 toast", async ({ + page, + }, testInfo) => { + skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID"); + + await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!)); + await waitForMessageListReady(page, { requireMessages: false }); + + await page.route("**/*", async (route) => { + const request = route.request(); + if (request.method() === "POST" && /\/runs\b/.test(request.url())) { + await route.abort("failed"); + return; + } + await route.continue(); + }); + + const textarea = page.locator("textarea[name='message']"); + const submit = page.locator("button[aria-label='Submit']"); + const errorToasts = page.locator('[data-sonner-toast][data-type="error"]'); + + await textarea.fill("触发重复错误 toast 测试 1"); + await submit.click({ force: true }); + + await expect(errorToasts.first()).toBeVisible({ timeout: 10_000 }); + await expect(errorToasts).toHaveCount(1); + + // 在去重窗口(2s)内再次触发同类错误,不应新增 toast + await textarea.fill("触发重复错误 toast 测试 2"); + await submit.click({ force: true }); + + await expect(errorToasts).toHaveCount(1); + }); +});