fix(frontend): 同意对话错误提示和增加两条e2e测试
This commit is contained in:
parent
56931c6c8b
commit
e45e355dbb
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue