fix(frontend): 同意对话错误提示和增加两条e2e测试

This commit is contained in:
肖应宇 2026-04-11 09:38:05 +08:00 committed by Titan
parent 56931c6c8b
commit e45e355dbb
3 changed files with 170 additions and 16 deletions

View File

@ -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;

View File

@ -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<object>();
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<string | null>(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,

View File

@ -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);
});
});