diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index c0f11b69e..ff40e3b60 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -393,8 +393,8 @@ "actions": { "back": "Back", "view": "View", - "copyMessages": "Copy Messages", - "downloadMessages": "Download Messages", + "copyMessages": "Copy Request (Headers + Body)", + "downloadMessages": "Download Request (Headers + Body)", "copied": "Copied", "copyResponse": "Copy Response", "terminate": "Terminate", @@ -436,7 +436,14 @@ "nextPage": "Next", "pageInfo": "Page {page} / {total}", "sseEvent": "Event", - "sseData": "Data" + "sseData": "Data", + "hardLimit": { + "title": "Content too large", + "size": "Size: {sizeMB} MB ({sizeBytes} bytes)", + "maximum": "Maximum allowed: {maxSizeMB} MB or {maxLines} lines", + "hint": "Please download the file to view the full content.", + "download": "Download" + } }, "status": { "loading": "Loading...", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 5fa0fed92..4111670ed 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -392,8 +392,8 @@ "actions": { "back": "戻る", "view": "表示", - "copyMessages": "メッセージをコピー", - "downloadMessages": "メッセージをダウンロード", + "copyMessages": "リクエスト(ヘッダーとボディ)をコピー", + "downloadMessages": "リクエスト(ヘッダーとボディ)をダウンロード", "copied": "コピーしました", "copyResponse": "レスポンスボディをコピー", "terminate": "強制終了", @@ -435,7 +435,14 @@ "nextPage": "次へ", "pageInfo": "{page} / {total} ページ", "sseEvent": "イベント", - "sseData": "データ" + "sseData": "データ", + "hardLimit": { + "title": "コンテンツが大きすぎます", + "size": "サイズ: {sizeMB} MB ({sizeBytes} bytes)", + "maximum": "上限: {maxSizeMB} MB または {maxLines} 行", + "hint": "全内容を表示するにはダウンロードしてください。", + "download": "ダウンロード" + } }, "status": { "loading": "読み込み中...", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 89a990bd6..942ebb537 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -392,8 +392,8 @@ "actions": { "back": "Назад", "view": "Просмотр", - "copyMessages": "Копировать сообщения", - "downloadMessages": "Скачать сообщения", + "copyMessages": "Копировать запрос (заголовки и тело)", + "downloadMessages": "Скачать запрос (заголовки и тело)", "copied": "Скопировано", "copyResponse": "Копировать тело ответа", "terminate": "Прервать", @@ -435,7 +435,14 @@ "nextPage": "Вперёд", "pageInfo": "Страница {page} / {total}", "sseEvent": "Событие", - "sseData": "Данные" + "sseData": "Данные", + "hardLimit": { + "title": "Содержимое слишком большое", + "size": "Размер: {sizeMB} MB ({sizeBytes} bytes)", + "maximum": "Максимум: {maxSizeMB} MB или {maxLines} строк", + "hint": "Пожалуйста, скачайте файл, чтобы посмотреть весь контент.", + "download": "Скачать" + } }, "status": { "loading": "Загрузка...", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index c76913423..dfad6f083 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -393,8 +393,8 @@ "actions": { "back": "返回", "view": "查看", - "copyMessages": "复制 Messages", - "downloadMessages": "下载 Messages", + "copyMessages": "复制请求头和请求体", + "downloadMessages": "下载请求头和请求体", "copied": "已复制", "copyResponse": "复制响应体", "terminate": "终止", @@ -436,7 +436,14 @@ "nextPage": "下一页", "pageInfo": "第 {page} / {total} 页", "sseEvent": "事件", - "sseData": "数据" + "sseData": "数据", + "hardLimit": { + "title": "内容过大", + "size": "大小:{sizeMB} MB({sizeBytes} 字节)", + "maximum": "上限:{maxSizeMB} MB 或 {maxLines} 行", + "hint": "请下载文件以查看完整内容。", + "download": "下载" + } }, "status": { "loading": "加载中...", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 5a40f09d1..40c18f295 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -393,8 +393,8 @@ "actions": { "back": "返回", "view": "檢視", - "copyMessages": "複製 Messages", - "downloadMessages": "下載 Messages", + "copyMessages": "複製請求頭與請求體", + "downloadMessages": "下載請求頭與請求體", "copied": "已複製", "copyResponse": "複製回覆主體", "terminate": "終止", @@ -436,7 +436,14 @@ "nextPage": "下一頁", "pageInfo": "第 {page} / {total} 頁", "sseEvent": "事件", - "sseData": "資料" + "sseData": "資料", + "hardLimit": { + "title": "內容過大", + "size": "大小:{sizeMB} MB({sizeBytes} 位元組)", + "maximum": "上限:{maxSizeMB} MB 或 {maxLines} 行", + "hint": "請下載檔案以查看完整內容。", + "download": "下載" + } }, "status": { "loading": "載入中...", 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 index 433ef6163..ab560ae67 100644 --- 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 @@ -8,6 +8,9 @@ import { isSSEText } from "@/lib/utils/sse"; export type SessionMessages = Record | Record[]; +const SESSION_DETAILS_MAX_CONTENT_BYTES = 5_000_000; +const SESSION_DETAILS_MAX_LINES = 30_000; + function formatHeaders( headers: Record | null, preambleLines?: string[] @@ -135,6 +138,8 @@ export function SessionMessagesDetailsTabs({ content={formattedRequestHeaders} language="text" fileName="request.headers" + maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES} + maxLines={SESSION_DETAILS_MAX_LINES} maxHeight="600px" defaultExpanded expandedMaxHeight={codeExpandedMaxHeight} @@ -150,6 +155,8 @@ export function SessionMessagesDetailsTabs({ content={requestBodyContent} language="json" fileName="request.json" + maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES} + maxLines={SESSION_DETAILS_MAX_LINES} maxHeight="600px" defaultExpanded expandedMaxHeight={codeExpandedMaxHeight} @@ -165,6 +172,8 @@ export function SessionMessagesDetailsTabs({ content={requestMessagesContent} language="json" fileName="request.messages.json" + maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES} + maxLines={SESSION_DETAILS_MAX_LINES} maxHeight="600px" defaultExpanded expandedMaxHeight={codeExpandedMaxHeight} @@ -180,6 +189,8 @@ export function SessionMessagesDetailsTabs({ content={formattedResponseHeaders} language="text" fileName="response.headers" + maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES} + maxLines={SESSION_DETAILS_MAX_LINES} maxHeight="600px" defaultExpanded expandedMaxHeight={codeExpandedMaxHeight} @@ -195,6 +206,8 @@ export function SessionMessagesDetailsTabs({ content={response} language={responseLanguage} fileName={responseLanguage === "sse" ? "response.sse" : "response.json"} + maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES} + maxLines={SESSION_DETAILS_MAX_LINES} maxHeight="600px" defaultExpanded expandedMaxHeight={codeExpandedMaxHeight} diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx new file mode 100644 index 000000000..9611f1456 --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx @@ -0,0 +1,462 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SessionMessagesClient } from "./session-messages-client"; + +vi.mock("@tanstack/react-query", () => { + return { + useQuery: () => ({ data: { currencyDisplay: "USD" } }), + }; +}); + +vi.mock("next-intl", () => { + const t = (key: string) => key; + return { + useTranslations: () => t, + }; +}); + +let seqParamValue: string | null = null; +vi.mock("next/navigation", () => { + return { + useParams: () => ({ sessionId: "0123456789abcdef" }), + useSearchParams: () => ({ + get: (key: string) => { + if (key !== "seq") return null; + return seqParamValue; + }, + }), + }; +}); + +const routerReplaceMock = vi.fn(); +const routerPushMock = vi.fn(); +const routerBackMock = vi.fn(); + +vi.mock("@/i18n/routing", () => { + return { + useRouter: () => ({ + replace: routerReplaceMock, + push: routerPushMock, + back: routerBackMock, + }), + usePathname: () => "/dashboard/sessions/0123456789abcdef/messages", + }; +}); + +const getSessionDetailsMock = vi.fn(); +const terminateActiveSessionMock = vi.fn(); +vi.mock("@/actions/active-sessions", () => { + return { + getSessionDetails: (...args: unknown[]) => getSessionDetailsMock(...args), + terminateActiveSession: (...args: unknown[]) => terminateActiveSessionMock(...args), + }; +}); + +vi.mock("sonner", () => { + return { + toast: { + success: () => {}, + error: () => {}, + }, + }; +}); + +vi.mock("./request-list-sidebar", () => { + return { + RequestListSidebar: () =>
, + }; +}); + +vi.mock("./session-details-tabs", () => { + return { + SessionMessagesDetailsTabs: () =>
, + }; +}); + +function renderClient(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 })); + }); +} + +async function clickAsync(el: Element) { + await act(async () => { + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + // 让事件处理器内的 await 续体在 act 作用域内完成 + await Promise.resolve(); + }); +} + +async function flushEffects() { + // SessionMessagesClient 内部有异步 useEffect(await getSessionDetails + 多次 setState)。 + // 这里用两轮 tick 来确保状态更新都在 act 范围内落地,避免 act 警告。 + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +afterEach(() => { + getSessionDetailsMock.mockReset(); + terminateActiveSessionMock.mockReset(); + routerReplaceMock.mockReset(); + routerPushMock.mockReset(); + routerBackMock.mockReset(); + vi.useRealTimers(); + seqParamValue = null; +}); + +describe("SessionMessagesClient (request export actions)", () => { + test("selected seq in URL overrides currentSequence for request export", async () => { + seqParamValue = "3"; + getSessionDetailsMock.mockResolvedValue({ + ok: true, + data: { + requestBody: { model: "gpt-5.2", input: "hi" }, + messages: { role: "user", content: "hi" }, + response: '{"ok":true}', + requestHeaders: { "content-type": "application/json", "x-test": "1" }, + responseHeaders: { "x-res": "1" }, + requestMeta: { + clientUrl: "https://client.example/v1/responses", + upstreamUrl: "https://upstream.example/v1/responses", + method: "POST", + }, + responseMeta: { upstreamUrl: "https://upstream.example/v1/responses", statusCode: 200 }, + sessionStats: null, + currentSequence: 7, + prevSequence: null, + nextSequence: null, + }, + }); + + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockImplementation(() => "blob:mock"); + const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement"); + let lastAnchor: HTMLAnchorElement | null = null; + createElementSpy.mockImplementation(((tagName: string) => { + const el = originalCreateElement(tagName); + if (tagName === "a") { + lastAnchor = el as HTMLAnchorElement; + } + return el; + }) as unknown as typeof document.createElement); + + const { container, unmount } = renderClient(); + await flushEffects(); + + const buttons = Array.from(container.querySelectorAll("button")); + const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages")); + expect(downloadBtn).not.toBeUndefined(); + click(downloadBtn as HTMLButtonElement); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + const anchor = lastAnchor as HTMLAnchorElement | null; + if (!anchor) throw new Error("anchor not created"); + expect(anchor.download).toBe("session-01234567-seq-3-request.json"); + expect(anchor.href).toBe("blob:mock"); + + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock"); + expect(clickSpy).toHaveBeenCalledTimes(1); + + unmount(); + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + clickSpy.mockRestore(); + createElementSpy.mockRestore(); + }); + + test("copy/download exports include request headers and body", async () => { + getSessionDetailsMock.mockResolvedValue({ + ok: true, + data: { + requestBody: { model: "gpt-5.2", input: "hi" }, + messages: { role: "user", content: "hi" }, + response: '{"ok":true}', + requestHeaders: { "content-type": "application/json", "x-test": "1" }, + responseHeaders: { "x-res": "1" }, + requestMeta: { + clientUrl: "https://client.example/v1/responses", + upstreamUrl: "https://upstream.example/v1/responses", + method: "POST", + }, + responseMeta: { upstreamUrl: "https://upstream.example/v1/responses", statusCode: 200 }, + sessionStats: null, + currentSequence: 7, + prevSequence: null, + nextSequence: null, + }, + }); + + const clipboardWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: clipboardWriteText }, + configurable: true, + }); + + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockImplementation(() => "blob:mock"); + const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement"); + let lastAnchor: HTMLAnchorElement | null = null; + createElementSpy.mockImplementation(((tagName: string) => { + const el = originalCreateElement(tagName); + if (tagName === "a") { + lastAnchor = el as HTMLAnchorElement; + } + return el; + }) as unknown as typeof document.createElement); + + const { container, unmount } = renderClient(); + await flushEffects(); + + // 复制按钮内部会设置 2s 的回滚定时器;用 fake timers 避免 act 警告 + vi.useFakeTimers(); + + const expectedJson = JSON.stringify( + { + sessionId: "0123456789abcdef", + sequence: 7, + meta: { + clientUrl: "https://client.example/v1/responses", + upstreamUrl: "https://upstream.example/v1/responses", + method: "POST", + }, + headers: { "content-type": "application/json", "x-test": "1" }, + body: { model: "gpt-5.2", input: "hi" }, + }, + null, + 2 + ); + + const buttons = Array.from(container.querySelectorAll("button")); + const copyBtn = buttons.find((b) => b.textContent?.includes("actions.copyMessages")); + expect(copyBtn).not.toBeUndefined(); + await clickAsync(copyBtn as HTMLButtonElement); + expect(clipboardWriteText).toHaveBeenCalledWith(expectedJson); + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); + + const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages")); + expect(downloadBtn).not.toBeUndefined(); + click(downloadBtn as HTMLButtonElement); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + const anchor = lastAnchor as HTMLAnchorElement | null; + if (!anchor) throw new Error("anchor not created"); + expect(anchor.download).toBe("session-01234567-seq-7-request.json"); + expect(anchor.href).toBe("blob:mock"); + + const blob = createObjectURLSpy.mock.calls[0]?.[0] as Blob; + expect(await blob.text()).toBe(expectedJson); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock"); + expect(clickSpy).toHaveBeenCalledTimes(1); + + unmount(); + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + clickSpy.mockRestore(); + createElementSpy.mockRestore(); + }); + + test("does not render export buttons when request headers/body are missing", async () => { + getSessionDetailsMock.mockResolvedValue({ + ok: true, + data: { + requestBody: null, + messages: { role: "user", content: "hi" }, + response: null, + requestHeaders: null, + responseHeaders: null, + requestMeta: { clientUrl: null, upstreamUrl: null, method: null }, + responseMeta: { upstreamUrl: null, statusCode: null }, + sessionStats: null, + currentSequence: 1, + prevSequence: null, + nextSequence: null, + }, + }); + + const { container, unmount } = renderClient(); + await flushEffects(); + + expect(container.textContent).not.toContain("actions.copyMessages"); + expect(container.textContent).not.toContain("actions.downloadMessages"); + + unmount(); + }); + + test("does not render export buttons when request body is missing but headers exist", async () => { + getSessionDetailsMock.mockResolvedValue({ + ok: true, + data: { + requestBody: null, + messages: { role: "user", content: "hi" }, + response: null, + requestHeaders: { "content-type": "application/json" }, + responseHeaders: null, + requestMeta: { + clientUrl: "https://client.example/v1/responses", + upstreamUrl: "https://upstream.example/v1/responses", + method: "POST", + }, + responseMeta: { upstreamUrl: null, statusCode: null }, + sessionStats: null, + currentSequence: 1, + prevSequence: null, + nextSequence: null, + }, + }); + + const { container, unmount } = renderClient(); + await flushEffects(); + + expect(container.textContent).not.toContain("actions.copyMessages"); + expect(container.textContent).not.toContain("actions.downloadMessages"); + + unmount(); + }); + + test("shows error when getSessionDetails returns ok:false", async () => { + getSessionDetailsMock.mockResolvedValue({ + ok: false, + error: "ERR_FETCH", + }); + + const { container, unmount } = renderClient(); + await flushEffects(); + + expect(container.textContent).toContain("ERR_FETCH"); + + unmount(); + }); + + test("renders session stats view and supports nav/copy/terminate flows", async () => { + getSessionDetailsMock.mockResolvedValue({ + ok: true, + data: { + requestBody: { model: "gpt-5.2", input: "hi" }, + messages: { role: "user", content: "hi" }, + response: '{"ok":true}', + requestHeaders: { "content-type": "application/json" }, + responseHeaders: { "x-res": "1" }, + requestMeta: { clientUrl: null, upstreamUrl: null, method: "POST" }, + responseMeta: { upstreamUrl: null, statusCode: 200 }, + sessionStats: { + userAgent: "UA", + requestCount: 3, + firstRequestAt: "2026-01-01T00:00:00.000Z", + lastRequestAt: "2026-01-01T00:01:00.000Z", + totalDurationMs: 1500, + providers: [{ id: 1, name: "p1" }], + models: ["gpt-5.2"], + totalInputTokens: 10, + totalOutputTokens: 20, + totalCacheCreationTokens: 30, + totalCacheReadTokens: 40, + cacheTtlApplied: "mixed", + totalCostUsd: "0.123456", + }, + currentSequence: 7, + prevSequence: 6, + nextSequence: 8, + }, + }); + + const clipboardWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: clipboardWriteText }, + configurable: true, + }); + + const { container, unmount } = renderClient(); + await flushEffects(); + + // 上/下一个请求按钮应触发 router.replace + const buttons = Array.from(container.querySelectorAll("button")); + const prevBtn = buttons.find((b) => b.textContent?.includes("details.prevRequest")); + const nextBtn = buttons.find((b) => b.textContent?.includes("details.nextRequest")); + expect(prevBtn).not.toBeUndefined(); + expect(nextBtn).not.toBeUndefined(); + click(prevBtn as HTMLButtonElement); + click(nextBtn as HTMLButtonElement); + expect(routerReplaceMock).toHaveBeenCalledWith( + "/dashboard/sessions/0123456789abcdef/messages?seq=6" + ); + expect(routerReplaceMock).toHaveBeenCalledWith( + "/dashboard/sessions/0123456789abcdef/messages?seq=8" + ); + + // 复制响应体 + const copyRespBtn = buttons.find((b) => b.textContent?.includes("actions.copyResponse")); + expect(copyRespBtn).not.toBeUndefined(); + vi.useFakeTimers(); + await clickAsync(copyRespBtn as HTMLButtonElement); + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); + expect(clipboardWriteText).toHaveBeenCalledWith('{"ok":true}'); + + // 终止会话:打开弹窗并确认 + const terminateBtn = buttons.find((b) => b.textContent?.includes("actions.terminate")); + expect(terminateBtn).not.toBeUndefined(); + click(terminateBtn as HTMLButtonElement); + await act(async () => { + await Promise.resolve(); + }); + + terminateActiveSessionMock.mockResolvedValue({ ok: true }); + const confirmBtn = Array.from(document.querySelectorAll("button")).find((b) => + b.textContent?.includes("actions.confirmTerminate") + ); + expect(confirmBtn).not.toBeUndefined(); + await clickAsync(confirmBtn as HTMLButtonElement); + + expect(terminateActiveSessionMock).toHaveBeenCalledWith("0123456789abcdef"); + expect(routerPushMock).toHaveBeenCalledWith("/dashboard/sessions"); + + unmount(); + }); +}); 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 index 5117041b9..1f388d8cc 100644 --- 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 @@ -6,7 +6,7 @@ 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 { describe, expect, test, vi } from "vitest"; import { SessionMessagesDetailsTabs } from "./session-details-tabs"; const messages = { @@ -38,6 +38,13 @@ const messages = { pageInfo: "Page {page} / {total}", sseEvent: "Event", sseData: "Data", + hardLimit: { + title: "Content too large", + size: "Size: {sizeMB} MB ({sizeBytes} bytes)", + maximum: "Maximum allowed: {maxSizeMB} MB or {maxLines} lines", + hint: "Please download the file to view the full content.", + download: "Download", + }, }, }, }, @@ -186,4 +193,102 @@ describe("SessionMessagesDetailsTabs", () => { unmount(); }); + + test("uses larger hard-limit threshold (<= 30,000 lines) for request headers", () => { + const requestHeaders = Object.fromEntries( + Array.from({ length: 10_100 }, (_, i) => [`x-h-${i}`, `v-${i}`]) + ); + + const { container, unmount } = renderWithIntl( + + ); + + const requestHeadersTrigger = container.querySelector( + "[data-testid='session-tab-trigger-request-headers']" + ) as HTMLElement; + click(requestHeadersTrigger); + + const requestHeadersTab = container.querySelector( + "[data-testid='session-tab-request-headers']" + ) as HTMLElement; + expect(requestHeadersTab.textContent).not.toContain("Content too large"); + + const search = requestHeadersTab.querySelector( + "[data-testid='code-display-search']" + ) as HTMLInputElement; + expect(search).not.toBeNull(); + + unmount(); + }); + + test("hard-limited request body provides in-panel download for request.json", async () => { + const requestBody = Array.from({ length: 30_001 }, (_, i) => i); + const expectedJson = JSON.stringify(requestBody, null, 2); + + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockImplementation(() => "blob:mock"); + const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement"); + let lastAnchor: HTMLAnchorElement | null = null; + createElementSpy.mockImplementation(((tagName: string) => { + const el = originalCreateElement(tagName); + if (tagName === "a") { + lastAnchor = el as HTMLAnchorElement; + } + return el; + }) as unknown as typeof document.createElement); + + const { container, unmount } = renderWithIntl( + + ); + + const requestBodyTab = container.querySelector( + "[data-testid='session-tab-request-body']" + ) as HTMLElement; + expect(requestBodyTab.textContent).toContain("Content too large"); + expect(requestBodyTab.textContent).toContain("30,000 lines"); + + const downloadBtn = requestBodyTab.querySelector( + "[data-testid='code-display-hard-limit-download']" + ) as HTMLButtonElement; + expect(downloadBtn).not.toBeNull(); + click(downloadBtn); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + const anchor = lastAnchor as HTMLAnchorElement | null; + if (!anchor) throw new Error("anchor not created"); + expect(anchor.download).toBe("request.json"); + expect(anchor.href).toBe("blob:mock"); + + const blob = createObjectURLSpy.mock.calls[0]?.[0] as Blob; + expect(await blob.text()).toBe(expectedJson); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock"); + expect(clickSpy).toHaveBeenCalledTimes(1); + + unmount(); + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + clickSpy.mockRestore(); + createElementSpy.mockRestore(); + }); }); 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 411081b9d..4327662ea 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 @@ -78,12 +78,26 @@ export function SessionMessagesClient() { const [nextSequence, setNextSequence] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [copiedMessages, setCopiedMessages] = useState(false); + const [copiedRequest, setCopiedRequest] = useState(false); const [copiedResponse, setCopiedResponse] = useState(false); const [showTerminateDialog, setShowTerminateDialog] = useState(false); const [isTerminating, setIsTerminating] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const resetDetailsState = useCallback(() => { + setMessages(null); + setRequestBody(null); + setResponse(null); + setRequestHeaders(null); + setResponseHeaders(null); + setRequestMeta({ clientUrl: null, upstreamUrl: null, method: null }); + setResponseMeta({ upstreamUrl: null, statusCode: null }); + setSessionStats(null); + setCurrentSequence(null); + setPrevSequence(null); + setNextSequence(null); + }, []); + const { data: systemSettings } = useQuery({ queryKey: ["system-settings"], queryFn: fetchSystemSettings, @@ -127,16 +141,12 @@ export function SessionMessagesClient() { setPrevSequence(result.data.prevSequence); setNextSequence(result.data.nextSequence); } else { - setRequestBody(null); - setRequestMeta({ clientUrl: null, upstreamUrl: null, method: null }); - setResponseMeta({ upstreamUrl: null, statusCode: null }); + resetDetailsState(); setError(result.error || t("status.fetchFailed")); } } catch (err) { if (cancelled) return; - setRequestBody(null); - setRequestMeta({ clientUrl: null, upstreamUrl: null, method: null }); - setResponseMeta({ upstreamUrl: null, statusCode: null }); + resetDetailsState(); setError(err instanceof Error ? err.message : t("status.unknownError")); } finally { if (!cancelled) { @@ -150,15 +160,32 @@ export function SessionMessagesClient() { return () => { cancelled = true; }; - }, [sessionId, selectedSeq, t]); + }, [resetDetailsState, sessionId, selectedSeq, t]); + + const canExportRequest = + !isLoading && error === null && requestHeaders !== null && requestBody !== null; + const exportSequence = selectedSeq ?? currentSequence; + const getRequestExportJson = () => { + return JSON.stringify( + { + sessionId, + sequence: exportSequence, + meta: requestMeta, + headers: requestHeaders, + body: requestBody, + }, + null, + 2 + ); + }; - const handleCopyMessages = async () => { - if (!messages) return; + const handleCopyRequest = async () => { + if (!canExportRequest) return; try { - await navigator.clipboard.writeText(JSON.stringify(messages, null, 2)); - setCopiedMessages(true); - setTimeout(() => setCopiedMessages(false), 2000); + await navigator.clipboard.writeText(getRequestExportJson()); + setCopiedRequest(true); + setTimeout(() => setCopiedRequest(false), 2000); } catch (err) { console.error(t("errors.copyFailed"), err); } @@ -176,15 +203,16 @@ export function SessionMessagesClient() { } }; - const handleDownload = () => { - if (!messages) return; + const handleDownloadRequest = () => { + if (!canExportRequest) return; - const jsonStr = JSON.stringify(messages, null, 2); + const jsonStr = getRequestExportJson(); const blob = new Blob([jsonStr], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `session-${sessionId.substring(0, 8)}-messages.json`; + const seqPart = exportSequence !== null ? `-seq-${exportSequence}` : ""; + a.download = `session-${sessionId.substring(0, 8)}${seqPart}-request.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -253,15 +281,15 @@ export function SessionMessagesClient() { {/* 操作按钮 */}
- {messages !== null && ( + {canExportRequest && ( <> - diff --git a/src/components/ui/__tests__/code-display.test.tsx b/src/components/ui/__tests__/code-display.test.tsx index 015b4a375..49f0bb77f 100644 --- a/src/components/ui/__tests__/code-display.test.tsx +++ b/src/components/ui/__tests__/code-display.test.tsx @@ -6,7 +6,7 @@ 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 { describe, expect, test, vi } from "vitest"; import { CodeDisplay } from "@/components/ui/code-display"; const messages = { @@ -29,6 +29,13 @@ const messages = { pageInfo: "Page {page} / {total}", sseEvent: "Event", sseData: "Data", + hardLimit: { + title: "Content too large", + size: "Size: {sizeMB} MB ({sizeBytes} bytes)", + maximum: "Maximum allowed: {maxSizeMB} MB or {maxLines} lines", + hint: "Please download the file to view the full content.", + download: "Download", + }, }, }, }, @@ -293,6 +300,52 @@ describe("CodeDisplay", () => { unmount(); }); + test("hard-limited content provides download action", async () => { + const hugeContent = "x".repeat(1_000_001); + + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockImplementation(() => "blob:mock"); + const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement"); + let lastAnchor: HTMLAnchorElement | null = null; + createElementSpy.mockImplementation(((tagName: string) => { + const el = originalCreateElement(tagName); + if (tagName === "a") { + lastAnchor = el as HTMLAnchorElement; + } + return el; + }) as unknown as typeof document.createElement); + + const { container, unmount } = renderWithIntl( + + ); + + const downloadBtn = container.querySelector( + "[data-testid='code-display-hard-limit-download']" + ) as HTMLButtonElement; + expect(downloadBtn).not.toBeNull(); + click(downloadBtn); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + expect(lastAnchor?.download).toBe("huge.txt"); + expect(lastAnchor?.href).toBe("blob:mock"); + + const blob = createObjectURLSpy.mock.calls[0]?.[0] as Blob; + expect(await blob.text()).toBe(hugeContent); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock"); + expect(clickSpy).toHaveBeenCalledTimes(1); + + unmount(); + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + clickSpy.mockRestore(); + createElementSpy.mockRestore(); + }); + test("should show error for too many lines", () => { const manyLines = Array.from({ length: 10_001 }, (_, i) => `line ${i}`).join("\n"); const { container, unmount } = renderWithIntl( diff --git a/src/components/ui/code-display.tsx b/src/components/ui/code-display.tsx index 17c5751b6..c1a36c877 100644 --- a/src/components/ui/code-display.tsx +++ b/src/components/ui/code-display.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDown, ChevronUp, File as FileIcon, Search } from "lucide-react"; +import { ChevronDown, ChevronUp, Download, File as FileIcon, Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useRef, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; @@ -13,8 +13,9 @@ import { parseSSEDataForDisplay } from "@/lib/utils/sse"; export type CodeDisplayLanguage = "json" | "sse" | "text"; -const MAX_CONTENT_SIZE = 1_000_000; // 1MB -const MAX_LINES = 10_000; +const DEFAULT_MAX_CONTENT_BYTES = 1_000_000; // 1MB +const DEFAULT_MAX_LINES = 10_000; +const PRETTY_MODE_DEFAULT_MAX_CHARS = 100_000; export interface CodeDisplayProps { content: string; @@ -23,6 +24,8 @@ export interface CodeDisplayProps { maxHeight?: string; expandedMaxHeight?: string; defaultExpanded?: boolean; + maxContentBytes?: number; + maxLines?: number; } function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } { @@ -65,11 +68,22 @@ export function CodeDisplay({ maxHeight = "600px", expandedMaxHeight, defaultExpanded = false, + maxContentBytes, + maxLines, }: CodeDisplayProps) { const t = useTranslations("dashboard.sessions"); - const isOverMaxBytes = content.length > MAX_CONTENT_SIZE; - - const [mode, setMode] = useState<"raw" | "pretty">(getDefaultMode(language)); + const resolvedMaxContentBytes = maxContentBytes ?? DEFAULT_MAX_CONTENT_BYTES; + const resolvedMaxLines = maxLines ?? DEFAULT_MAX_LINES; + const contentBytes = useMemo(() => new Blob([content]).size, [content]); + const isOverMaxBytes = contentBytes > resolvedMaxContentBytes; + + const [mode, setMode] = useState<"raw" | "pretty">(() => { + const defaultMode = getDefaultMode(language); + if (defaultMode === "pretty" && content.length > PRETTY_MODE_DEFAULT_MAX_CHARS) { + return "raw"; + } + return defaultMode; + }); const [searchQuery, setSearchQuery] = useState(""); const [showOnlyMatches, setShowOnlyMatches] = useState(false); const [expanded, setExpanded] = useState(defaultExpanded); @@ -90,27 +104,36 @@ export function CodeDisplay({ return () => observer.disconnect(); }, []); + useEffect(() => { + if (mode !== "pretty") return; + if (language === "text") return; + if (content.length <= PRETTY_MODE_DEFAULT_MAX_CHARS) return; + setMode("raw"); + }, [content, language, mode]); + const lineCount = useMemo(() => { if (isOverMaxBytes) return 0; - return countLinesUpTo(content, MAX_LINES + 1); - }, [content, isOverMaxBytes]); + return countLinesUpTo(content, resolvedMaxLines + 1); + }, [content, isOverMaxBytes, resolvedMaxLines]); const isLargeContent = content.length > 4000 || lineCount > 200; const isExpanded = expanded || !isLargeContent; - const isHardLimited = isOverMaxBytes || lineCount > MAX_LINES; + const isHardLimited = isOverMaxBytes || lineCount > resolvedMaxLines; const formattedJson = useMemo(() => { if (language !== "json") return content; - if (isOverMaxBytes) return content; + if (mode !== "pretty") return content; + if (isHardLimited) return content; const parsed = safeJsonParse(content); if (!parsed.ok) return content; return stringifyPretty(parsed.value); - }, [content, isOverMaxBytes, language]); + }, [content, isHardLimited, language, mode]); const sseEvents = useMemo(() => { if (language !== "sse") return null; - if (isOverMaxBytes) return null; + if (mode !== "pretty") return null; + if (isHardLimited) return null; return parseSSEDataForDisplay(content); - }, [content, isOverMaxBytes, language]); + }, [content, isHardLimited, language, mode]); const filteredSseEvents = useMemo(() => { if (!sseEvents) return null; @@ -165,9 +188,30 @@ export function CodeDisplay({ const contentMaxHeight = isExpanded ? expandedMaxHeight : maxHeight; if (isHardLimited) { - const sizeBytes = content.length; + const sizeBytes = contentBytes; const sizeMB = (sizeBytes / 1_000_000).toFixed(2); - const maxSizeMB = (MAX_CONTENT_SIZE / 1_000_000).toFixed(2); + const maxSizeMB = (resolvedMaxContentBytes / 1_000_000).toFixed(2); + const downloadFileName = + fileName ?? + (language === "json" ? "content.json" : language === "sse" ? "content.sse" : "content.txt"); + const handleDownload = () => { + const blob = new Blob([content], { + type: language === "json" ? "application/json" : "text/plain", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + try { + a.href = url; + a.download = downloadFileName; + document.body.appendChild(a); + a.click(); + } finally { + if (a.isConnected) { + document.body.removeChild(a); + } + URL.revokeObjectURL(url); + } + }; return (
@@ -186,17 +230,33 @@ export function CodeDisplay({
-

Content too large

+

{t("codeDisplay.hardLimit.title")}

- Size: {sizeMB} MB ({sizeBytes.toLocaleString()} bytes) + {t("codeDisplay.hardLimit.size", { + sizeMB, + sizeBytes: sizeBytes.toLocaleString(), + })}

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

-

- Please download the file to view the full content. + {t("codeDisplay.hardLimit.maximum", { + maxSizeMB, + maxLines: resolvedMaxLines.toLocaleString(), + })}

+

{t("codeDisplay.hardLimit.hint")}

+
+ +