diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index d5dea30e2..36ca63352 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -411,6 +411,24 @@ "batchTerminateNone": "No sessions were terminated", "noSelection": "Please select at least one session" }, + "codeDisplay": { + "raw": "Raw", + "pretty": "Pretty", + "searchPlaceholder": "Search", + "expand": "Expand", + "collapse": "Collapse", + "themeAuto": "Auto", + "themeLight": "Light", + "themeDark": "Dark", + "noMatches": "No matches", + "onlyMatches": "Only matches", + "showAll": "Show all", + "prevPage": "Prev", + "nextPage": "Next", + "pageInfo": "Page {page} / {total}", + "sseEvent": "Event", + "sseData": "Data" + }, "status": { "loading": "Loading...", "loadError": "Load failed", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 82fe4be83..231054fc4 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -410,6 +410,24 @@ "batchTerminateNone": "終了できたセッションはありません", "noSelection": "少なくとも1つのセッションを選択してください" }, + "codeDisplay": { + "raw": "Raw", + "pretty": "Pretty", + "searchPlaceholder": "検索", + "expand": "展開", + "collapse": "折りたたむ", + "themeAuto": "自動", + "themeLight": "ライト", + "themeDark": "ダーク", + "noMatches": "一致する結果はありません", + "onlyMatches": "一致のみ", + "showAll": "すべて表示", + "prevPage": "前へ", + "nextPage": "次へ", + "pageInfo": "{page} / {total} ページ", + "sseEvent": "イベント", + "sseData": "データ" + }, "status": { "loading": "読み込み中...", "loadError": "読み込み失敗", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 61399411d..314147b82 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -410,6 +410,24 @@ "batchTerminateNone": "Не удалось прервать ни одной сессии", "noSelection": "Выберите хотя бы одну сессию" }, + "codeDisplay": { + "raw": "Сырой", + "pretty": "Форматированный", + "searchPlaceholder": "Поиск", + "expand": "Развернуть", + "collapse": "Свернуть", + "themeAuto": "Авто", + "themeLight": "Светлая", + "themeDark": "Тёмная", + "noMatches": "Нет совпадений", + "onlyMatches": "Только совпадения", + "showAll": "Показать всё", + "prevPage": "Назад", + "nextPage": "Вперёд", + "pageInfo": "Страница {page} / {total}", + "sseEvent": "Событие", + "sseData": "Данные" + }, "status": { "loading": "Загрузка...", "loadError": "Ошибка загрузки", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 377416cb7..af9d7859f 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -411,6 +411,24 @@ "batchTerminateNone": "没有任何 Session 被终止", "noSelection": "请至少选择一个 Session" }, + "codeDisplay": { + "raw": "原始", + "pretty": "美化", + "searchPlaceholder": "搜索", + "expand": "展开", + "collapse": "收起", + "themeAuto": "跟随系统", + "themeLight": "浅色", + "themeDark": "深色", + "noMatches": "无匹配结果", + "onlyMatches": "仅匹配行", + "showAll": "显示全部", + "prevPage": "上一页", + "nextPage": "下一页", + "pageInfo": "第 {page} / {total} 页", + "sseEvent": "事件", + "sseData": "数据" + }, "status": { "loading": "加载中...", "loadError": "加载失败", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 6c63e6f96..9e7d1490f 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -411,6 +411,24 @@ "batchTerminateNone": "沒有任何會話被終止", "noSelection": "請至少選擇一個會話" }, + "codeDisplay": { + "raw": "原始", + "pretty": "美化", + "searchPlaceholder": "搜尋", + "expand": "展開", + "collapse": "收起", + "themeAuto": "跟隨系統", + "themeLight": "淺色", + "themeDark": "深色", + "noMatches": "沒有符合結果", + "onlyMatches": "僅符合行", + "showAll": "顯示全部", + "prevPage": "上一頁", + "nextPage": "下一頁", + "pageInfo": "第 {page} / {total} 頁", + "sseEvent": "事件", + "sseData": "資料" + }, "status": { "loading": "載入中...", "loadError": "載入失敗", diff --git a/package.json b/package.json index b5aadf742..9d7731d6e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "clean:cache": "rm -rf .next tsconfig.tsbuildinfo node_modules/.cache", "test": "vitest run", "test:ui": "vitest --ui --watch", - "test:e2e": "vitest run tests/e2e/ --reporter=verbose", + "test:e2e": "vitest run --config vitest.e2e.config.ts --reporter=verbose", "test:coverage": "vitest run --coverage", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml", "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e", @@ -75,6 +75,7 @@ "react-day-picker": "^9", "react-dom": "^19", "react-hook-form": "^7", + "react-syntax-highlighter": "^16.1.0", "recharts": "^3", "safe-regex": "^2", "server-only": "^0.0.1", @@ -94,6 +95,7 @@ "@types/pg": "^8", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", "@typescript/native-preview": "7.0.0-dev.20251219.1", "@vitest/coverage-v8": "^4.0.16", "@vitest/ui": "^4.0.16", diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts index f77e7e256..eab5ef39b 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -581,6 +581,20 @@ export async function getSessionDetails( const normalizedSequence = normalizeRequestSequence(requestSequence); const effectiveSequence = normalizedSequence ?? (requestCount > 0 ? requestCount : undefined); + const parseJsonStringOrNull = (value: unknown): unknown => { + if (typeof value !== "string") return value; + try { + return JSON.parse(value) as unknown; + } catch (error) { + logger.warn("getSessionDetails: failed to parse session messages JSON string", { + sessionId, + requestSequence: effectiveSequence ?? null, + error, + }); + return null; + } + }; + // 6. 并行获取 messages 和 response(不缓存,因为这些数据较大) const [messages, response, requestHeaders, responseHeaders] = await Promise.all([ SessionManager.getSessionMessages(sessionId, effectiveSequence), @@ -589,10 +603,13 @@ export async function getSessionDetails( SessionManager.getSessionResponseHeaders(sessionId, effectiveSequence), ]); + // 兼容:历史/异常数据可能是 JSON 字符串(前端需要根级对象/数组) + const normalizedMessages = parseJsonStringOrNull(messages); + return { ok: true, data: { - messages, + messages: normalizedMessages, response, requestHeaders, responseHeaders, diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx new file mode 100644 index 000000000..385585550 --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; +import { CodeDisplay } from "@/components/ui/code-display"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { isSSEText } from "@/lib/utils/sse"; + +export type SessionMessages = Record | Record[]; + +function formatHeaders(headers: Record | null): string | null { + if (!headers || Object.keys(headers).length === 0) return null; + return Object.entries(headers) + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); +} + +interface SessionMessagesDetailsTabsProps { + messages: SessionMessages | null; + requestHeaders: Record | null; + responseHeaders: Record | null; + response: string | null; +} + +export function SessionMessagesDetailsTabs({ + messages, + response, + requestHeaders, + responseHeaders, +}: SessionMessagesDetailsTabsProps) { + const t = useTranslations("dashboard.sessions"); + + const requestBodyContent = useMemo(() => { + if (messages === null) return null; + return JSON.stringify(messages, null, 2); + }, [messages]); + + const formattedRequestHeaders = useMemo(() => formatHeaders(requestHeaders), [requestHeaders]); + const formattedResponseHeaders = useMemo(() => formatHeaders(responseHeaders), [responseHeaders]); + + const responseLanguage = response && isSSEText(response) ? "sse" : "json"; + + return ( + + + + {t("details.requestHeaders")} + + + {t("details.requestBody")} + + + {t("details.responseHeaders")} + + + {t("details.responseBody")} + + + + + {formattedRequestHeaders === null ? ( +
{t("details.noHeaders")}
+ ) : ( + + )} +
+ + + {requestBodyContent === null ? ( +
{t("details.noData")}
+ ) : ( + + )} +
+ + + {formattedResponseHeaders === null ? ( +
{t("details.noHeaders")}
+ ) : ( + + )} +
+ + + {response === null ? ( +
{t("details.noData")}
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx new file mode 100644 index 000000000..0881e3d75 --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx @@ -0,0 +1,151 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test } from "vitest"; +import { SessionMessagesDetailsTabs } from "./session-details-tabs"; + +const messages = { + dashboard: { + sessions: { + details: { + requestHeaders: "Request Headers", + requestBody: "Request Body", + responseHeaders: "Response Headers", + responseBody: "Response Body", + noHeaders: "No data", + noData: "No Data", + }, + codeDisplay: { + raw: "Raw", + pretty: "Pretty", + searchPlaceholder: "Search", + expand: "Expand", + collapse: "Collapse", + themeAuto: "Auto", + themeLight: "Light", + themeDark: "Dark", + noMatches: "No matches", + onlyMatches: "Only matches", + showAll: "Show all", + prevPage: "Prev", + nextPage: "Next", + pageInfo: "Page {page} / {total}", + sseEvent: "Event", + sseData: "Data", + }, + }, + }, +} as const; + +function renderWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function click(el: Element) { + act(() => { + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("SessionMessagesDetailsTabs", () => { + test("uses CodeDisplay for request/response/headers and detects SSE response", () => { + const sse = ["event: foo", 'data: {"x":1}', "", "data: [DONE]"].join("\n"); + + const { container, unmount } = renderWithIntl( + + ); + + expect(container.querySelector("[data-testid='session-details-tabs']")).not.toBeNull(); + + const requestBody = container.querySelector( + "[data-testid='session-tab-request-body'] [data-testid='code-display']" + ) as HTMLElement; + expect(requestBody.getAttribute("data-language")).toBe("json"); + + const responseBodyTrigger = container.querySelector( + "[data-testid='session-tab-trigger-response-body']" + ) as HTMLElement; + click(responseBodyTrigger); + + const responseBody = container.querySelector( + "[data-testid='session-tab-response-body'] [data-testid='code-display']" + ) as HTMLElement; + expect(responseBody.getAttribute("data-language")).toBe("sse"); + + unmount(); + }); + + test("detects JSON response when response is not SSE", () => { + const { container, unmount } = renderWithIntl( + + ); + + const responseBodyTrigger = container.querySelector( + "[data-testid='session-tab-trigger-response-body']" + ) as HTMLElement; + click(responseBodyTrigger); + + const responseBody = container.querySelector( + "[data-testid='session-tab-response-body'] [data-testid='code-display']" + ) as HTMLElement; + expect(responseBody.getAttribute("data-language")).toBe("json"); + + unmount(); + }); + + test("renders empty states for missing data", () => { + const { container, unmount } = renderWithIntl( + + ); + + expect(container.textContent).toContain("No Data"); + + const requestHeadersTrigger = container.querySelector( + "[data-testid='session-tab-trigger-request-headers']" + ) as HTMLElement; + click(requestHeadersTrigger); + expect(container.textContent).toContain("No data"); + + unmount(); + }); +}); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx index 2361137b4..5a48df2b3 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx @@ -21,10 +21,11 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { usePathname, useRouter } from "@/i18n/routing"; import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; import { RequestListSidebar } from "./request-list-sidebar"; +import { type SessionMessages, SessionMessagesDetailsTabs } from "./session-details-tabs"; +import { isSessionMessages } from "./session-messages-guards"; async function fetchSystemSettings(): Promise<{ currencyDisplay: CurrencyCode; @@ -36,10 +37,6 @@ async function fetchSystemSettings(): Promise<{ return response.json(); } -/** - * Session Messages 详情页面客户端组件 - * 三栏布局:左侧请求列表 + 中间完整内容 + 右侧信息卡片 - */ export function SessionMessagesClient() { const t = useTranslations("dashboard.sessions"); const tDesc = useTranslations("dashboard.description"); @@ -58,7 +55,7 @@ export function SessionMessagesClient() { return parsed; })(); - const [messages, setMessages] = useState(null); + const [messages, setMessages] = useState(null); const [response, setResponse] = useState(null); const [requestHeaders, setRequestHeaders] = useState | null>(null); const [responseHeaders, setResponseHeaders] = useState | null>(null); @@ -93,6 +90,8 @@ export function SessionMessagesClient() { ); useEffect(() => { + let cancelled = false; + const fetchDetails = async () => { setIsLoading(true); setError(null); @@ -100,8 +99,11 @@ export function SessionMessagesClient() { try { // 传入 requestSequence 参数以获取特定请求的消息 const result = await getSessionDetails(sessionId, selectedSeq ?? undefined); + if (cancelled) return; + if (result.ok) { - setMessages(result.data.messages); + const maybeMessages = result.data.messages; + setMessages(isSessionMessages(maybeMessages) ? maybeMessages : null); setResponse(result.data.response); setRequestHeaders(result.data.requestHeaders); setResponseHeaders(result.data.responseHeaders); @@ -111,13 +113,20 @@ export function SessionMessagesClient() { setError(result.error || t("status.fetchFailed")); } } catch (err) { + if (cancelled) return; setError(err instanceof Error ? err.message : t("status.unknownError")); } finally { - setIsLoading(false); + if (!cancelled) { + setIsLoading(false); + } } }; void fetchDetails(); + + return () => { + cancelled = true; + }; }, [sessionId, selectedSeq, t]); const handleCopyMessages = async () => { @@ -178,16 +187,6 @@ export function SessionMessagesClient() { } }; - // 格式化响应体(尝试美化 JSON) - const formatResponse = (raw: string) => { - try { - const parsed = JSON.parse(raw); - return JSON.stringify(parsed, null, 2); - } catch { - return raw; - } - }; - // 计算总 Token(从聚合统计) const totalTokens = (sessionStats?.totalInputTokens || 0) + @@ -297,70 +296,36 @@ export function SessionMessagesClient() { )} - - - {t("details.requestHeaders")} - {t("details.requestBody")} - - {t("details.responseHeaders")} - - {t("details.responseBody")} - - - - - - - - {messages === null ? ( -
{t("details.noData")}
- ) : ( -
-
-                          {JSON.stringify(messages, null, 2)}
-                        
-
- )} -
- - - - - - - {response === null ? ( -
{t("details.noData")}
- ) : ( -
-
- -
-
-
-                            {formatResponse(response)}
-                          
-
-
- )} -
-
+
+ {response !== null && ( +
+ +
+ )} + +
{/* 无数据提示 */} {!sessionStats?.userAgent && @@ -614,19 +579,3 @@ export function SessionMessagesClient() { ); } - -function HeadersDisplay({ headers }: { headers: Record | null }) { - const t = useTranslations("dashboard.sessions"); - if (!headers || Object.keys(headers).length === 0) { - return
{t("details.noHeaders")}
; - } - return ( -
-
-        {Object.entries(headers)
-          .map(([key, value]) => `${key}: ${value}`)
-          .join("\n")}
-      
-
- ); -} diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-guards.test.ts b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-guards.test.ts new file mode 100644 index 000000000..e61d9555d --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-guards.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { isSessionMessages } from "./session-messages-guards"; + +describe("isSessionMessages type guard", () => { + test("accepts valid single non-empty record object", () => { + expect(isSessionMessages({ role: "user", content: "hi" })).toBe(true); + }); + + test("accepts valid non-empty array of non-empty record objects", () => { + expect(isSessionMessages([{ role: "user" }, { role: "assistant" }])).toBe(true); + }); + + test("rejects empty array", () => { + expect(isSessionMessages([])).toBe(false); + }); + + test("rejects empty object", () => { + expect(isSessionMessages({})).toBe(false); + }); + + test("rejects array containing empty object", () => { + expect(isSessionMessages([{ role: "user" }, {}])).toBe(false); + }); + + test("rejects array with non-record items", () => { + expect(isSessionMessages(["string", 123, null])).toBe(false); + }); + + test("rejects primitives", () => { + expect(isSessionMessages(null)).toBe(false); + expect(isSessionMessages("string")).toBe(false); + expect(isSessionMessages(123)).toBe(false); + }); + + test("rejects special objects like Date/RegExp", () => { + expect(isSessionMessages(new Date())).toBe(false); + expect(isSessionMessages(/re/)).toBe(false); + }); +}); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-guards.ts b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-guards.ts new file mode 100644 index 000000000..f41566aad --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-guards.ts @@ -0,0 +1,20 @@ +import type { SessionMessages } from "./session-details-tabs"; + +export function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object") return false; + if (Array.isArray(value)) return false; + return Object.prototype.toString.call(value) === "[object Object]"; +} + +function isNonEmptyPlainRecord(value: unknown): value is Record { + return isPlainRecord(value) && Object.keys(value).length > 0; +} + +export function isSessionMessages(value: unknown): value is SessionMessages { + if (Array.isArray(value)) { + if (value.length === 0) return false; + return value.every((item) => isNonEmptyPlainRecord(item)); + } + + return isNonEmptyPlainRecord(value); +} diff --git a/src/components/ui/__tests__/code-display.test.tsx b/src/components/ui/__tests__/code-display.test.tsx new file mode 100644 index 000000000..e3f8ba91f --- /dev/null +++ b/src/components/ui/__tests__/code-display.test.tsx @@ -0,0 +1,357 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { CodeDisplay } from "@/components/ui/code-display"; + +const messages = { + dashboard: { + sessions: { + codeDisplay: { + raw: "Raw", + pretty: "Pretty", + searchPlaceholder: "Search", + expand: "Expand", + collapse: "Collapse", + themeAuto: "Auto", + themeLight: "Light", + themeDark: "Dark", + noMatches: "No matches", + onlyMatches: "Only matches", + showAll: "Show all", + prevPage: "Prev", + nextPage: "Next", + pageInfo: "Page {page} / {total}", + sseEvent: "Event", + sseData: "Data", + }, + }, + }, +} as const; + +function renderWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function click(el: Element) { + act(() => { + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("CodeDisplay", () => { + test("json pretty shows formatted output; raw shows original", () => { + const { container, unmount } = renderWithIntl( + + ); + + const root = container.querySelector("[data-testid='code-display']"); + expect(root).not.toBeNull(); + + // default: pretty for json + expect(container.textContent).toContain('"a": 1'); + + const rawTab = container.querySelector("[data-testid='code-display-mode-raw']") as HTMLElement; + click(rawTab); + expect(container.textContent).toContain('{"a":1}'); + + unmount(); + }); + + test("raw mode renders HTML-like content as text (no script/img elements)", () => { + const malicious = `Hello`; + const { container, unmount } = renderWithIntl( + + ); + + const pre = container.querySelector("pre") as HTMLElement; + expect(pre).not.toBeNull(); + expect(pre.textContent).toContain(malicious); + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("img")).toBeNull(); + + unmount(); + }); + + test("sse pretty renders events and supports search filtering", () => { + const sse = [ + "event: foo", + 'data: {"x":1}', + "", + "event: bar", + "data: hello", + "", + "data: [DONE]", + ].join("\n"); + + const { container, unmount } = renderWithIntl( + + ); + + // default: pretty for sse; [DONE] is dropped => 2 rows + expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(2); + + const input = container.querySelector( + "[data-testid='code-display-search']" + ) as HTMLInputElement; + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "bar"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(1); + expect(container.textContent).toContain("bar"); + + unmount(); + }); + + test("sse preview renders data as text (no HTML parsing; truncated and non-truncated)", () => { + const short = ``; + const long = `${"x".repeat(200)}`; + const sse = ["event: foo", `data: ${short}`, "", "event: bar", `data: ${long}`, ""].join("\n"); + + const { container, unmount } = renderWithIntl(); + + const summaries = Array.from(container.querySelectorAll("summary")); + expect(summaries.length).toBe(2); + expect(summaries[0]?.textContent).toContain(short); + expect(summaries[1]?.textContent).toContain(""); + expect(summaries[1]?.textContent).toContain("..."); + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("img")).toBeNull(); + + unmount(); + }); + + test("json pretty falls back when content is not valid JSON", () => { + const { container, unmount } = renderWithIntl( + + ); + + expect(container.textContent).toContain("not-json"); + unmount(); + }); + + test("text pretty renders and short content does not show expand toggle", () => { + const { container, unmount } = renderWithIntl(); + + const root = container.querySelector("[data-testid='code-display']") as HTMLElement; + expect(root.getAttribute("data-expanded")).toBe("true"); + expect(container.querySelector("[data-testid='code-display-expand-toggle']")).toBeNull(); + + const prettyTab = container.querySelector( + "[data-testid='code-display-mode-pretty']" + ) as HTMLElement; + click(prettyTab); + expect(container.textContent).toContain("hi"); + + unmount(); + }); + + test("handles empty content without crashing", () => { + const { container, unmount } = renderWithIntl(); + const root = container.querySelector("[data-testid='code-display']") as HTMLElement; + expect(root.getAttribute("data-expanded")).toBe("true"); + unmount(); + }); + + test("text search supports only-matches mode and shows no-matches hint", () => { + const content = ["L1-111", "L2-222", "L3-333"].join("\n"); + const { container, unmount } = renderWithIntl( + + ); + + const input = container.querySelector( + "[data-testid='code-display-search']" + ) as HTMLInputElement; + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "NOPE"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const toggle = container.querySelector( + "[data-testid='code-display-only-matches-toggle']" + ) as HTMLElement; + click(toggle); + expect(container.textContent).toContain("No matches"); + + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "L2-222"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const pre = container.querySelector("pre") as HTMLElement; + expect(pre.textContent).toContain("L2-222"); + expect(pre.textContent).not.toContain("L1-111"); + + unmount(); + }); + + test("sse pagination and no-matches branch", () => { + const lines: string[] = []; + for (let i = 1; i <= 9; i += 1) { + lines.push("event: evt", `data: ${i}`, ""); + } + lines.push("event: evt", `data: ${"x".repeat(200)}`, ""); + lines.push("event: evt", "data: 11", ""); + const sse = lines.join("\n"); + const { container, unmount } = renderWithIntl( + + ); + + expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(10); + + const next = container.querySelector( + "[data-testid='code-display-page-next']" + ) as HTMLButtonElement; + click(next); + expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(1); + + const prev = container.querySelector( + "[data-testid='code-display-page-prev']" + ) as HTMLButtonElement; + click(prev); + expect(container.querySelectorAll("[data-testid='code-display-sse-row']").length).toBe(10); + + const input = container.querySelector( + "[data-testid='code-display-search']" + ) as HTMLInputElement; + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "does-not-exist"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.textContent).toContain("No matches"); + + unmount(); + }); + + test("auto theme uses matchMedia when available", async () => { + const original = window.matchMedia; + const add = vi.fn(); + const remove = vi.fn(); + + // @ts-expect-error test stub + window.matchMedia = () => ({ + matches: true, + addEventListener: add, + removeEventListener: remove, + }); + + const { unmount } = renderWithIntl(); + await act(async () => { + await Promise.resolve(); + }); + expect(add).toHaveBeenCalled(); + + unmount(); + window.matchMedia = original; + }); + + test("auto theme is a no-op when matchMedia is missing", () => { + const original = window.matchMedia; + // @ts-expect-error test stub + window.matchMedia = undefined; + + const { unmount } = renderWithIntl(); + unmount(); + window.matchMedia = original; + }); + + test("large content shows expand/collapse and toggles expanded state", () => { + const long = "x".repeat(5000); + const { container, unmount } = renderWithIntl( + + ); + + const expand = container.querySelector( + "[data-testid='code-display-expand-toggle']" + ) as HTMLButtonElement; + expect(expand).not.toBeNull(); + + const root = container.querySelector("[data-testid='code-display']") as HTMLElement; + expect(root.getAttribute("data-expanded")).toBe("false"); + click(expand); + expect(root.getAttribute("data-expanded")).toBe("true"); + click(expand); + expect(root.getAttribute("data-expanded")).toBe("false"); + + unmount(); + }); + + test("should show error for oversized content", () => { + const hugeContent = "x".repeat(1_000_001); + const { container, unmount } = renderWithIntl( + + ); + + expect(container.textContent).toContain("Content too large"); + expect(container.textContent).toContain("1.00 MB"); + unmount(); + }); + + test("should show error for too many lines", () => { + const manyLines = Array.from({ length: 10_001 }, (_, i) => `line ${i}`).join("\n"); + const { container, unmount } = renderWithIntl( + + ); + + expect(container.textContent).toContain("Content too large"); + expect(container.textContent).toContain("10,000 lines"); + unmount(); + }); + + test("theme toggles update data-theme", () => { + const { container, unmount } = renderWithIntl( + + ); + + const root = container.querySelector("[data-testid='code-display']") as HTMLElement; + expect(root.getAttribute("data-theme")).toBe("auto"); + + const dark = container.querySelector("[data-testid='code-display-theme-dark']") as HTMLElement; + click(dark); + expect(root.getAttribute("data-theme")).toBe("dark"); + + const light = container.querySelector( + "[data-testid='code-display-theme-light']" + ) as HTMLElement; + click(light); + expect(root.getAttribute("data-theme")).toBe("light"); + + const auto = container.querySelector("[data-testid='code-display-theme-auto']") as HTMLElement; + click(auto); + expect(root.getAttribute("data-theme")).toBe("auto"); + + unmount(); + }); +}); diff --git a/src/components/ui/code-display.tsx b/src/components/ui/code-display.tsx new file mode 100644 index 000000000..e5839d4cb --- /dev/null +++ b/src/components/ui/code-display.tsx @@ -0,0 +1,466 @@ +"use client"; + +import { ChevronDown, ChevronUp, File as FileIcon, Laptop, Moon, Search, Sun } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { parseSSEDataForDisplay } from "@/lib/utils/sse"; + +type ThemePreference = "auto" | "light" | "dark"; + +export type CodeDisplayLanguage = "json" | "sse" | "text"; + +const MAX_CONTENT_SIZE = 1_000_000; // 1MB +const MAX_LINES = 10_000; + +export interface CodeDisplayProps { + content: string; + language: CodeDisplayLanguage; + fileName?: string; + maxHeight?: string; +} + +function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +} + +function stringifyPretty(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function splitLines(text: string): string[] { + return text.length === 0 ? [""] : text.split("\n"); +} + +function countLinesUpTo(text: string, maxLines: number): number { + if (text.length === 0) return 1; + let count = 1; + for (let i = 0; i < text.length; i += 1) { + if (text.charCodeAt(i) === 10) { + count += 1; + if (count >= maxLines) return count; + } + } + return count; +} + +function getDefaultMode(language: CodeDisplayLanguage): "raw" | "pretty" { + if (language === "text") return "raw"; + return "pretty"; +} + +export function CodeDisplay({ + content, + language, + fileName, + maxHeight = "600px", +}: CodeDisplayProps) { + const t = useTranslations("dashboard.sessions"); + const isOverMaxBytes = content.length > MAX_CONTENT_SIZE; + + const [mode, setMode] = useState<"raw" | "pretty">(getDefaultMode(language)); + const [searchQuery, setSearchQuery] = useState(""); + const [showOnlyMatches, setShowOnlyMatches] = useState(false); + const [expanded, setExpanded] = useState(false); + const [themePreference, setThemePreference] = useState("auto"); + const [page, setPage] = useState(1); + const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light"); + + useEffect(() => { + if (!window.matchMedia) return; + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const update = () => setSystemTheme(media.matches ? "dark" : "light"); + update(); + + if (media.addEventListener) { + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + } + + media.addListener(update); + return () => media.removeListener(update); + }, []); + + const effectiveTheme: ThemePreference = themePreference; + const resolvedEffectiveTheme = effectiveTheme === "auto" ? systemTheme : effectiveTheme; + + const lineCount = useMemo(() => { + if (isOverMaxBytes) return 0; + return countLinesUpTo(content, MAX_LINES + 1); + }, [content, isOverMaxBytes]); + const isLargeContent = content.length > 4000 || lineCount > 200; + const isExpanded = expanded || !isLargeContent; + const isHardLimited = isOverMaxBytes || lineCount > MAX_LINES; + + const formattedJson = useMemo(() => { + if (language !== "json") return content; + if (isOverMaxBytes) return content; + const parsed = safeJsonParse(content); + if (!parsed.ok) return content; + return stringifyPretty(parsed.value); + }, [content, isOverMaxBytes, language]); + + const sseEvents = useMemo(() => { + if (language !== "sse") return null; + if (isOverMaxBytes) return null; + return parseSSEDataForDisplay(content); + }, [content, isOverMaxBytes, language]); + + const filteredSseEvents = useMemo(() => { + if (!sseEvents) return null; + const q = searchQuery.trim().toLowerCase(); + if (!q) return sseEvents; + + return sseEvents.filter((evt) => { + const eventText = evt.event.toLowerCase(); + const dataText = typeof evt.data === "string" ? evt.data : JSON.stringify(evt.data, null, 2); + return eventText.includes(q) || dataText.toLowerCase().includes(q); + }); + }, [searchQuery, sseEvents]); + + const lineFilteredText = useMemo(() => { + if (language === "sse") return null; + if (isOverMaxBytes) return content; + const q = searchQuery.trim(); + if (!q || !showOnlyMatches) return content; + const lines = splitLines(content); + const matches = lines.filter((line) => line.includes(q)); + return matches.length === 0 ? "" : matches.join("\n"); + }, [content, isOverMaxBytes, language, searchQuery, showOnlyMatches]); + + type SseEvent = ReturnType[number]; + const pagination = useMemo((): { + pageSize: number; + totalPages: number; + page: number; + items: SseEvent[]; + } => { + if (!filteredSseEvents) { + return { pageSize: 10, totalPages: 1, page: 1, items: [] }; + } + const pageSize = 10; + const totalPages = Math.max(1, Math.ceil(filteredSseEvents.length / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + const start = (safePage - 1) * pageSize; + const end = start + pageSize; + return { + pageSize, + totalPages, + page: safePage, + items: filteredSseEvents.slice(start, end), + }; + }, [filteredSseEvents, page]); + + const highlighterStyle = resolvedEffectiveTheme === "dark" ? oneDark : oneLight; + const displayText = lineFilteredText ?? content; + + if (isHardLimited) { + const sizeBytes = content.length; + const sizeMB = (sizeBytes / 1_000_000).toFixed(2); + const maxSizeMB = (MAX_CONTENT_SIZE / 1_000_000).toFixed(2); + + return ( +
+
+
+ {fileName && ( + {fileName} + )} + + {language.toUpperCase()} + +
+
+ +
+
+
+ +

Content too large

+
+

+ Size: {sizeMB} MB ({sizeBytes.toLocaleString()} bytes) +

+

+ Maximum allowed: {maxSizeMB} MB or {MAX_LINES.toLocaleString()} lines +

+

+ Please download the file to view the full content. +

+
+
+
+ ); + } + + const headerRight = ( +
+
+ + { + setSearchQuery(e.target.value); + setPage(1); + }} + placeholder={t("codeDisplay.searchPlaceholder")} + className="pl-8 h-9" + /> +
+ + {language !== "sse" && ( + + )} + +
+ + + +
+ + {isLargeContent && ( + + )} +
+ ); + + return ( +
+
+
+ {fileName && ( + {fileName} + )} + + {language.toUpperCase()} + +
+ {headerRight} +
+ +
+ setMode(v as "raw" | "pretty")} className="w-full"> + + + {t("codeDisplay.raw")} + + + {t("codeDisplay.pretty")} + + + + +
{displayText}
+
+ + + {language === "json" ? ( + + {formattedJson} + + ) : language === "sse" ? ( +
+ + + + # + {t("codeDisplay.sseEvent")} + {t("codeDisplay.sseData")} + + + + {pagination.items.map((evt, idx) => { + const rowIndex = (pagination.page - 1) * pagination.pageSize + idx + 1; + const dataText = + typeof evt.data === "string" ? evt.data : stringifyPretty(evt.data); + + return ( + + + {rowIndex} + + {evt.event} + +
+ + {dataText.length > 120 ? `${dataText.slice(0, 120)}...` : dataText} + +
+ + {dataText} + +
+
+
+
+ ); + })} +
+
+ +
+
+ {t("codeDisplay.pageInfo", { + page: pagination.page, + total: pagination.totalPages, + })} +
+
+ + +
+
+ + {filteredSseEvents && filteredSseEvents.length === 0 && ( +
{t("codeDisplay.noMatches")}
+ )} +
+ ) : ( + + {displayText} + + )} +
+
+ + {searchQuery.trim() && + language !== "sse" && + showOnlyMatches && + (lineFilteredText ?? "") === "" && ( +
{t("codeDisplay.noMatches")}
+ )} +
+
+ ); +} diff --git a/src/lib/utils/sse.test.ts b/src/lib/utils/sse.test.ts new file mode 100644 index 000000000..3d578ce1a --- /dev/null +++ b/src/lib/utils/sse.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import { isSSEText, parseSSEData, parseSSEDataForDisplay } from "./sse"; + +describe("sse utils", () => { + test("isSSEText detects standard SSE by line prefixes", () => { + expect( + isSSEText( + [ + "event: content_block_delta", + 'data: {"type":"content_block_delta"}', + "", + "data: [DONE]", + ].join("\n") + ) + ).toBe(true); + expect(isSSEText('{"data":123}')).toBe(false); + expect(isSSEText("not sse\ndata: nope")).toBe(false); + expect(isSSEText("")).toBe(false); + expect(isSSEText([": keep-alive", "data: 1"].join("\n"))).toBe(true); + }); + + test("parseSSEDataForDisplay parses and drops [DONE]", () => { + const events = parseSSEDataForDisplay( + [ + "event: message", + 'data: {"a":1}', + "", + "event: message", + "data: hello", + "", + "data: [DONE]", + ].join("\n") + ); + + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ event: "message", data: { a: 1 } }); + expect(events[1]).toEqual({ event: "message", data: "hello" }); + }); + + test("parseSSEData strips the leading single space after data:", () => { + const events = parseSSEData(["event: e", "data: 1", ""].join("\n")); + expect(events).toEqual([{ event: "e", data: 1 }]); + }); + + test("parseSSEData keeps data value when there is no space after data:", () => { + const events = parseSSEData(["event: e", "data:1", ""].join("\n")); + expect(events).toEqual([{ event: "e", data: 1 }]); + }); + + test("parseSSEData ignores unsupported SSE fields (e.g. id:)", () => { + const events = parseSSEData(["id: 1", "data: 1", ""].join("\n")); + expect(events).toEqual([{ event: "message", data: 1 }]); + }); + + test("parseSSEDataForDisplay supports data-only events and multi-line JSON", () => { + const events = parseSSEDataForDisplay(["data: {", 'data: "k": 1', "data: }", ""].join("\n")); + expect(events).toHaveLength(1); + expect(events[0]?.event).toBe("message"); + expect(events[0]?.data).toEqual({ k: 1 }); + }); + + test("parseSSEDataForDisplay ignores comments and flushes on blank line", () => { + const events = parseSSEDataForDisplay( + [": keep-alive", "event: e", "data: 1", "", ""].join("\n") + ); + expect(events).toEqual([{ event: "e", data: 1 }]); + }); +}); diff --git a/src/lib/utils/sse.ts b/src/lib/utils/sse.ts index efed88b7e..936bf663b 100644 --- a/src/lib/utils/sse.ts +++ b/src/lib/utils/sse.ts @@ -63,3 +63,36 @@ export function parseSSEData(sseText: string): ParsedSSEEvent[] { return events; } + +/** + * 严格检测文本是否“看起来像” SSE。 + * + * 只认行首的 `event:` / `data:`(或前置注释行 `:`),避免 JSON 里包含 "data:" 误判。 + */ +export function isSSEText(text: string): boolean { + let start = 0; + + for (let i = 0; i <= text.length; i += 1) { + if (i !== text.length && text.charCodeAt(i) !== 10) continue; // '\n' + + const line = text.slice(start, i).trim(); + start = i + 1; + + if (!line) continue; + if (line.startsWith(":")) continue; + + return line.startsWith("event:") || line.startsWith("data:"); + } + + return false; +} + +/** + * 用于 UI 展示的 SSE 解析(在 parseSSEData 基础上做轻量清洗)。 + */ +export function parseSSEDataForDisplay(sseText: string): ParsedSSEEvent[] { + return parseSSEData(sseText).filter((evt) => { + if (typeof evt.data !== "string") return true; + return evt.data.trim() !== "[DONE]"; + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 15a3cbaf3..d361c6527 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,19 @@ export default defineConfig({ test: { // ==================== 全局配置 ==================== globals: true, // 使用全局 API (describe, test, expect) - environment: "node", // Node.js 环境(服务端测试) + projects: [ + { + extends: true, + test: { + environment: "happy-dom", + include: [ + "tests/unit/**/*.{test,spec}.tsx", + "tests/api/**/*.{test,spec}.tsx", + "src/**/*.{test,spec}.tsx", + ], + }, + }, + ], // 测试前置脚本 setupFiles: ["./tests/setup.ts"], @@ -51,8 +63,9 @@ export default defineConfig({ // ==================== 文件匹配 ==================== include: [ - "tests/**/*.test.ts", // 所有测试文件 - "src/**/*.{test,spec}.{ts,tsx}", // 支持源码中的测试 + "tests/unit/**/*.{test,spec}.ts", // 单元测试 + "tests/api/**/*.{test,spec}.ts", // API 测试 + "src/**/*.{test,spec}.ts", // 支持源码中的测试 ], exclude: [ "node_modules", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 000000000..7fdbc9e85 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + setupFiles: ["./tests/setup.ts"], + api: { + host: process.env.VITEST_API_HOST || "127.0.0.1", + port: Number(process.env.VITEST_API_PORT || 51204), + strictPort: false, + }, + open: false, + testTimeout: 10000, + hookTimeout: 10000, + maxConcurrency: 5, + pool: "threads", + include: ["tests/e2e/**/*.{test,spec}.ts"], + exclude: [ + "node_modules", + ".next", + "dist", + "build", + "coverage", + "**/*.d.ts", + "tests/integration/**", + ], + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + resolveSnapshotPath: (testPath, snapExtension) => { + return testPath.replace(/\.test\.([tj]sx?)$/, `${snapExtension}.$1`); + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +});