Просмотр исходного кода

fix: Session Messages 提升内容阈值并导出完整请求 (#537)

Ding 1 месяц назад
Родитель
Сommit
7c13cc63c5

+ 10 - 3
messages/en/dashboard.json

@@ -393,8 +393,8 @@
     "actions": {
     "actions": {
       "back": "Back",
       "back": "Back",
       "view": "View",
       "view": "View",
-      "copyMessages": "Copy Messages",
-      "downloadMessages": "Download Messages",
+      "copyMessages": "Copy Request (Headers + Body)",
+      "downloadMessages": "Download Request (Headers + Body)",
       "copied": "Copied",
       "copied": "Copied",
       "copyResponse": "Copy Response",
       "copyResponse": "Copy Response",
       "terminate": "Terminate",
       "terminate": "Terminate",
@@ -436,7 +436,14 @@
       "nextPage": "Next",
       "nextPage": "Next",
       "pageInfo": "Page {page} / {total}",
       "pageInfo": "Page {page} / {total}",
       "sseEvent": "Event",
       "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": {
     "status": {
       "loading": "Loading...",
       "loading": "Loading...",

+ 10 - 3
messages/ja/dashboard.json

@@ -392,8 +392,8 @@
     "actions": {
     "actions": {
       "back": "戻る",
       "back": "戻る",
       "view": "表示",
       "view": "表示",
-      "copyMessages": "メッセージをコピー",
-      "downloadMessages": "メッセージをダウンロード",
+      "copyMessages": "リクエスト(ヘッダーとボディ)をコピー",
+      "downloadMessages": "リクエスト(ヘッダーとボディ)をダウンロード",
       "copied": "コピーしました",
       "copied": "コピーしました",
       "copyResponse": "レスポンスボディをコピー",
       "copyResponse": "レスポンスボディをコピー",
       "terminate": "強制終了",
       "terminate": "強制終了",
@@ -435,7 +435,14 @@
       "nextPage": "次へ",
       "nextPage": "次へ",
       "pageInfo": "{page} / {total} ページ",
       "pageInfo": "{page} / {total} ページ",
       "sseEvent": "イベント",
       "sseEvent": "イベント",
-      "sseData": "データ"
+      "sseData": "データ",
+      "hardLimit": {
+        "title": "コンテンツが大きすぎます",
+        "size": "サイズ: {sizeMB} MB ({sizeBytes} bytes)",
+        "maximum": "上限: {maxSizeMB} MB または {maxLines} 行",
+        "hint": "全内容を表示するにはダウンロードしてください。",
+        "download": "ダウンロード"
+      }
     },
     },
     "status": {
     "status": {
       "loading": "読み込み中...",
       "loading": "読み込み中...",

+ 10 - 3
messages/ru/dashboard.json

@@ -392,8 +392,8 @@
     "actions": {
     "actions": {
       "back": "Назад",
       "back": "Назад",
       "view": "Просмотр",
       "view": "Просмотр",
-      "copyMessages": "Копировать сообщения",
-      "downloadMessages": "Скачать сообщения",
+      "copyMessages": "Копировать запрос (заголовки и тело)",
+      "downloadMessages": "Скачать запрос (заголовки и тело)",
       "copied": "Скопировано",
       "copied": "Скопировано",
       "copyResponse": "Копировать тело ответа",
       "copyResponse": "Копировать тело ответа",
       "terminate": "Прервать",
       "terminate": "Прервать",
@@ -435,7 +435,14 @@
       "nextPage": "Вперёд",
       "nextPage": "Вперёд",
       "pageInfo": "Страница {page} / {total}",
       "pageInfo": "Страница {page} / {total}",
       "sseEvent": "Событие",
       "sseEvent": "Событие",
-      "sseData": "Данные"
+      "sseData": "Данные",
+      "hardLimit": {
+        "title": "Содержимое слишком большое",
+        "size": "Размер: {sizeMB} MB ({sizeBytes} bytes)",
+        "maximum": "Максимум: {maxSizeMB} MB или {maxLines} строк",
+        "hint": "Пожалуйста, скачайте файл, чтобы посмотреть весь контент.",
+        "download": "Скачать"
+      }
     },
     },
     "status": {
     "status": {
       "loading": "Загрузка...",
       "loading": "Загрузка...",

+ 10 - 3
messages/zh-CN/dashboard.json

@@ -393,8 +393,8 @@
     "actions": {
     "actions": {
       "back": "返回",
       "back": "返回",
       "view": "查看",
       "view": "查看",
-      "copyMessages": "复制 Messages",
-      "downloadMessages": "下载 Messages",
+      "copyMessages": "复制请求头和请求体",
+      "downloadMessages": "下载请求头和请求体",
       "copied": "已复制",
       "copied": "已复制",
       "copyResponse": "复制响应体",
       "copyResponse": "复制响应体",
       "terminate": "终止",
       "terminate": "终止",
@@ -436,7 +436,14 @@
       "nextPage": "下一页",
       "nextPage": "下一页",
       "pageInfo": "第 {page} / {total} 页",
       "pageInfo": "第 {page} / {total} 页",
       "sseEvent": "事件",
       "sseEvent": "事件",
-      "sseData": "数据"
+      "sseData": "数据",
+      "hardLimit": {
+        "title": "内容过大",
+        "size": "大小:{sizeMB} MB({sizeBytes} 字节)",
+        "maximum": "上限:{maxSizeMB} MB 或 {maxLines} 行",
+        "hint": "请下载文件以查看完整内容。",
+        "download": "下载"
+      }
     },
     },
     "status": {
     "status": {
       "loading": "加载中...",
       "loading": "加载中...",

+ 10 - 3
messages/zh-TW/dashboard.json

@@ -393,8 +393,8 @@
     "actions": {
     "actions": {
       "back": "返回",
       "back": "返回",
       "view": "檢視",
       "view": "檢視",
-      "copyMessages": "複製 Messages",
-      "downloadMessages": "下載 Messages",
+      "copyMessages": "複製請求頭與請求體",
+      "downloadMessages": "下載請求頭與請求體",
       "copied": "已複製",
       "copied": "已複製",
       "copyResponse": "複製回覆主體",
       "copyResponse": "複製回覆主體",
       "terminate": "終止",
       "terminate": "終止",
@@ -436,7 +436,14 @@
       "nextPage": "下一頁",
       "nextPage": "下一頁",
       "pageInfo": "第 {page} / {total} 頁",
       "pageInfo": "第 {page} / {total} 頁",
       "sseEvent": "事件",
       "sseEvent": "事件",
-      "sseData": "資料"
+      "sseData": "資料",
+      "hardLimit": {
+        "title": "內容過大",
+        "size": "大小:{sizeMB} MB({sizeBytes} 位元組)",
+        "maximum": "上限:{maxSizeMB} MB 或 {maxLines} 行",
+        "hint": "請下載檔案以查看完整內容。",
+        "download": "下載"
+      }
     },
     },
     "status": {
     "status": {
       "loading": "載入中...",
       "loading": "載入中...",

+ 13 - 0
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<string, unknown> | Record<string, unknown>[];
 export type SessionMessages = Record<string, unknown> | Record<string, unknown>[];
 
 
+const SESSION_DETAILS_MAX_CONTENT_BYTES = 5_000_000;
+const SESSION_DETAILS_MAX_LINES = 30_000;
+
 function formatHeaders(
 function formatHeaders(
   headers: Record<string, string> | null,
   headers: Record<string, string> | null,
   preambleLines?: string[]
   preambleLines?: string[]
@@ -135,6 +138,8 @@ export function SessionMessagesDetailsTabs({
             content={formattedRequestHeaders}
             content={formattedRequestHeaders}
             language="text"
             language="text"
             fileName="request.headers"
             fileName="request.headers"
+            maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES}
+            maxLines={SESSION_DETAILS_MAX_LINES}
             maxHeight="600px"
             maxHeight="600px"
             defaultExpanded
             defaultExpanded
             expandedMaxHeight={codeExpandedMaxHeight}
             expandedMaxHeight={codeExpandedMaxHeight}
@@ -150,6 +155,8 @@ export function SessionMessagesDetailsTabs({
             content={requestBodyContent}
             content={requestBodyContent}
             language="json"
             language="json"
             fileName="request.json"
             fileName="request.json"
+            maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES}
+            maxLines={SESSION_DETAILS_MAX_LINES}
             maxHeight="600px"
             maxHeight="600px"
             defaultExpanded
             defaultExpanded
             expandedMaxHeight={codeExpandedMaxHeight}
             expandedMaxHeight={codeExpandedMaxHeight}
@@ -165,6 +172,8 @@ export function SessionMessagesDetailsTabs({
             content={requestMessagesContent}
             content={requestMessagesContent}
             language="json"
             language="json"
             fileName="request.messages.json"
             fileName="request.messages.json"
+            maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES}
+            maxLines={SESSION_DETAILS_MAX_LINES}
             maxHeight="600px"
             maxHeight="600px"
             defaultExpanded
             defaultExpanded
             expandedMaxHeight={codeExpandedMaxHeight}
             expandedMaxHeight={codeExpandedMaxHeight}
@@ -180,6 +189,8 @@ export function SessionMessagesDetailsTabs({
             content={formattedResponseHeaders}
             content={formattedResponseHeaders}
             language="text"
             language="text"
             fileName="response.headers"
             fileName="response.headers"
+            maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES}
+            maxLines={SESSION_DETAILS_MAX_LINES}
             maxHeight="600px"
             maxHeight="600px"
             defaultExpanded
             defaultExpanded
             expandedMaxHeight={codeExpandedMaxHeight}
             expandedMaxHeight={codeExpandedMaxHeight}
@@ -195,6 +206,8 @@ export function SessionMessagesDetailsTabs({
             content={response}
             content={response}
             language={responseLanguage}
             language={responseLanguage}
             fileName={responseLanguage === "sse" ? "response.sse" : "response.json"}
             fileName={responseLanguage === "sse" ? "response.sse" : "response.json"}
+            maxContentBytes={SESSION_DETAILS_MAX_CONTENT_BYTES}
+            maxLines={SESSION_DETAILS_MAX_LINES}
             maxHeight="600px"
             maxHeight="600px"
             defaultExpanded
             defaultExpanded
             expandedMaxHeight={codeExpandedMaxHeight}
             expandedMaxHeight={codeExpandedMaxHeight}

+ 462 - 0
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: () => <div data-testid="mock-request-list-sidebar" />,
+  };
+});
+
+vi.mock("./session-details-tabs", () => {
+  return {
+    SessionMessagesDetailsTabs: () => <div data-testid="mock-session-details-tabs" />,
+  };
+});
+
+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(<SessionMessagesClient />);
+    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(<SessionMessagesClient />);
+    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(<SessionMessagesClient />);
+    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(<SessionMessagesClient />);
+    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(<SessionMessagesClient />);
+    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(<SessionMessagesClient />);
+    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();
+  });
+});

+ 106 - 1
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 { act } from "react";
 import { createRoot } from "react-dom/client";
 import { createRoot } from "react-dom/client";
 import { NextIntlClientProvider } from "next-intl";
 import { NextIntlClientProvider } from "next-intl";
-import { describe, expect, test } from "vitest";
+import { describe, expect, test, vi } from "vitest";
 import { SessionMessagesDetailsTabs } from "./session-details-tabs";
 import { SessionMessagesDetailsTabs } from "./session-details-tabs";
 
 
 const messages = {
 const messages = {
@@ -38,6 +38,13 @@ const messages = {
         pageInfo: "Page {page} / {total}",
         pageInfo: "Page {page} / {total}",
         sseEvent: "Event",
         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",
+        },
       },
       },
     },
     },
   },
   },
@@ -186,4 +193,102 @@ describe("SessionMessagesDetailsTabs", () => {
 
 
     unmount();
     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(
+      <SessionMessagesDetailsTabs
+        requestBody={null}
+        messages={{ role: "user", content: "hi" }}
+        response='{"ok":true}'
+        requestHeaders={requestHeaders}
+        responseHeaders={{ b: "2" }}
+        requestMeta={{ clientUrl: null, upstreamUrl: null, method: null }}
+        responseMeta={{ upstreamUrl: null, statusCode: null }}
+      />
+    );
+
+    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(
+      <SessionMessagesDetailsTabs
+        requestBody={requestBody}
+        messages={{ role: "user", content: "hi" }}
+        response='{"ok":true}'
+        requestHeaders={{ a: "1" }}
+        responseHeaders={{ b: "2" }}
+        requestMeta={{ clientUrl: null, upstreamUrl: null, method: null }}
+        responseMeta={{ upstreamUrl: null, statusCode: null }}
+      />
+    );
+
+    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();
+  });
 });
 });

+ 50 - 22
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx

@@ -78,12 +78,26 @@ export function SessionMessagesClient() {
   const [nextSequence, setNextSequence] = useState<number | null>(null);
   const [nextSequence, setNextSequence] = useState<number | null>(null);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
-  const [copiedMessages, setCopiedMessages] = useState(false);
+  const [copiedRequest, setCopiedRequest] = useState(false);
   const [copiedResponse, setCopiedResponse] = useState(false);
   const [copiedResponse, setCopiedResponse] = useState(false);
   const [showTerminateDialog, setShowTerminateDialog] = useState(false);
   const [showTerminateDialog, setShowTerminateDialog] = useState(false);
   const [isTerminating, setIsTerminating] = useState(false);
   const [isTerminating, setIsTerminating] = useState(false);
   const [sidebarCollapsed, setSidebarCollapsed] = 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({
   const { data: systemSettings } = useQuery({
     queryKey: ["system-settings"],
     queryKey: ["system-settings"],
     queryFn: fetchSystemSettings,
     queryFn: fetchSystemSettings,
@@ -127,16 +141,12 @@ export function SessionMessagesClient() {
           setPrevSequence(result.data.prevSequence);
           setPrevSequence(result.data.prevSequence);
           setNextSequence(result.data.nextSequence);
           setNextSequence(result.data.nextSequence);
         } else {
         } else {
-          setRequestBody(null);
-          setRequestMeta({ clientUrl: null, upstreamUrl: null, method: null });
-          setResponseMeta({ upstreamUrl: null, statusCode: null });
+          resetDetailsState();
           setError(result.error || t("status.fetchFailed"));
           setError(result.error || t("status.fetchFailed"));
         }
         }
       } catch (err) {
       } catch (err) {
         if (cancelled) return;
         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"));
         setError(err instanceof Error ? err.message : t("status.unknownError"));
       } finally {
       } finally {
         if (!cancelled) {
         if (!cancelled) {
@@ -150,15 +160,32 @@ export function SessionMessagesClient() {
     return () => {
     return () => {
       cancelled = true;
       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 {
     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) {
     } catch (err) {
       console.error(t("errors.copyFailed"), 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 blob = new Blob([jsonStr], { type: "application/json" });
     const url = URL.createObjectURL(blob);
     const url = URL.createObjectURL(blob);
     const a = document.createElement("a");
     const a = document.createElement("a");
     a.href = url;
     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);
     document.body.appendChild(a);
     a.click();
     a.click();
     document.body.removeChild(a);
     document.body.removeChild(a);
@@ -253,15 +281,15 @@ export function SessionMessagesClient() {
 
 
             {/* 操作按钮 */}
             {/* 操作按钮 */}
             <div className="flex gap-2">
             <div className="flex gap-2">
-              {messages !== null && (
+              {canExportRequest && (
                 <>
                 <>
                   <Button
                   <Button
                     variant="outline"
                     variant="outline"
                     size="sm"
                     size="sm"
-                    onClick={handleCopyMessages}
-                    disabled={copiedMessages}
+                    onClick={handleCopyRequest}
+                    disabled={copiedRequest}
                   >
                   >
-                    {copiedMessages ? (
+                    {copiedRequest ? (
                       <>
                       <>
                         <Check className="h-4 w-4 mr-2" />
                         <Check className="h-4 w-4 mr-2" />
                         {t("actions.copied")}
                         {t("actions.copied")}
@@ -273,7 +301,7 @@ export function SessionMessagesClient() {
                       </>
                       </>
                     )}
                     )}
                   </Button>
                   </Button>
-                  <Button variant="outline" size="sm" onClick={handleDownload}>
+                  <Button variant="outline" size="sm" onClick={handleDownloadRequest}>
                     <Download className="h-4 w-4 mr-2" />
                     <Download className="h-4 w-4 mr-2" />
                     {t("actions.downloadMessages")}
                     {t("actions.downloadMessages")}
                   </Button>
                   </Button>

+ 54 - 1
src/components/ui/__tests__/code-display.test.tsx

@@ -6,7 +6,7 @@ import type { ReactNode } from "react";
 import { act } from "react";
 import { act } from "react";
 import { createRoot } from "react-dom/client";
 import { createRoot } from "react-dom/client";
 import { NextIntlClientProvider } from "next-intl";
 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";
 import { CodeDisplay } from "@/components/ui/code-display";
 
 
 const messages = {
 const messages = {
@@ -29,6 +29,13 @@ const messages = {
         pageInfo: "Page {page} / {total}",
         pageInfo: "Page {page} / {total}",
         sseEvent: "Event",
         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",
+        },
       },
       },
     },
     },
   },
   },
@@ -293,6 +300,52 @@ describe("CodeDisplay", () => {
     unmount();
     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(
+      <CodeDisplay content={hugeContent} language="text" fileName="huge.txt" />
+    );
+
+    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", () => {
   test("should show error for too many lines", () => {
     const manyLines = Array.from({ length: 10_001 }, (_, i) => `line ${i}`).join("\n");
     const manyLines = Array.from({ length: 10_001 }, (_, i) => `line ${i}`).join("\n");
     const { container, unmount } = renderWithIntl(
     const { container, unmount } = renderWithIntl(

+ 81 - 21
src/components/ui/code-display.tsx

@@ -1,6 +1,6 @@
 "use client";
 "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 { useTranslations } from "next-intl";
 import { useEffect, useMemo, useRef, useState } from "react";
 import { useEffect, useMemo, useRef, useState } from "react";
 import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
 import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
@@ -13,8 +13,9 @@ import { parseSSEDataForDisplay } from "@/lib/utils/sse";
 
 
 export type CodeDisplayLanguage = "json" | "sse" | "text";
 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 {
 export interface CodeDisplayProps {
   content: string;
   content: string;
@@ -23,6 +24,8 @@ export interface CodeDisplayProps {
   maxHeight?: string;
   maxHeight?: string;
   expandedMaxHeight?: string;
   expandedMaxHeight?: string;
   defaultExpanded?: boolean;
   defaultExpanded?: boolean;
+  maxContentBytes?: number;
+  maxLines?: number;
 }
 }
 
 
 function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } {
 function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } {
@@ -65,11 +68,22 @@ export function CodeDisplay({
   maxHeight = "600px",
   maxHeight = "600px",
   expandedMaxHeight,
   expandedMaxHeight,
   defaultExpanded = false,
   defaultExpanded = false,
+  maxContentBytes,
+  maxLines,
 }: CodeDisplayProps) {
 }: CodeDisplayProps) {
   const t = useTranslations("dashboard.sessions");
   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 [searchQuery, setSearchQuery] = useState("");
   const [showOnlyMatches, setShowOnlyMatches] = useState(false);
   const [showOnlyMatches, setShowOnlyMatches] = useState(false);
   const [expanded, setExpanded] = useState(defaultExpanded);
   const [expanded, setExpanded] = useState(defaultExpanded);
@@ -90,27 +104,36 @@ export function CodeDisplay({
     return () => observer.disconnect();
     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(() => {
   const lineCount = useMemo(() => {
     if (isOverMaxBytes) return 0;
     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 isLargeContent = content.length > 4000 || lineCount > 200;
   const isExpanded = expanded || !isLargeContent;
   const isExpanded = expanded || !isLargeContent;
-  const isHardLimited = isOverMaxBytes || lineCount > MAX_LINES;
+  const isHardLimited = isOverMaxBytes || lineCount > resolvedMaxLines;
 
 
   const formattedJson = useMemo(() => {
   const formattedJson = useMemo(() => {
     if (language !== "json") return content;
     if (language !== "json") return content;
-    if (isOverMaxBytes) return content;
+    if (mode !== "pretty") return content;
+    if (isHardLimited) return content;
     const parsed = safeJsonParse(content);
     const parsed = safeJsonParse(content);
     if (!parsed.ok) return content;
     if (!parsed.ok) return content;
     return stringifyPretty(parsed.value);
     return stringifyPretty(parsed.value);
-  }, [content, isOverMaxBytes, language]);
+  }, [content, isHardLimited, language, mode]);
 
 
   const sseEvents = useMemo(() => {
   const sseEvents = useMemo(() => {
     if (language !== "sse") return null;
     if (language !== "sse") return null;
-    if (isOverMaxBytes) return null;
+    if (mode !== "pretty") return null;
+    if (isHardLimited) return null;
     return parseSSEDataForDisplay(content);
     return parseSSEDataForDisplay(content);
-  }, [content, isOverMaxBytes, language]);
+  }, [content, isHardLimited, language, mode]);
 
 
   const filteredSseEvents = useMemo(() => {
   const filteredSseEvents = useMemo(() => {
     if (!sseEvents) return null;
     if (!sseEvents) return null;
@@ -165,9 +188,30 @@ export function CodeDisplay({
   const contentMaxHeight = isExpanded ? expandedMaxHeight : maxHeight;
   const contentMaxHeight = isExpanded ? expandedMaxHeight : maxHeight;
 
 
   if (isHardLimited) {
   if (isHardLimited) {
-    const sizeBytes = content.length;
+    const sizeBytes = contentBytes;
     const sizeMB = (sizeBytes / 1_000_000).toFixed(2);
     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 (
     return (
       <div data-testid="code-display" className="rounded-md border bg-muted/30">
       <div data-testid="code-display" className="rounded-md border bg-muted/30">
@@ -186,17 +230,33 @@ export function CodeDisplay({
           <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
           <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
             <div className="flex items-center gap-2">
             <div className="flex items-center gap-2">
               <FileIcon className="h-4 w-4 text-destructive" />
               <FileIcon className="h-4 w-4 text-destructive" />
-              <p className="font-medium">Content too large</p>
+              <p className="font-medium">{t("codeDisplay.hardLimit.title")}</p>
             </div>
             </div>
             <p className="mt-1 text-sm">
             <p className="mt-1 text-sm">
-              Size: {sizeMB} MB ({sizeBytes.toLocaleString()} bytes)
+              {t("codeDisplay.hardLimit.size", {
+                sizeMB,
+                sizeBytes: sizeBytes.toLocaleString(),
+              })}
             </p>
             </p>
             <p className="text-sm">
             <p className="text-sm">
-              Maximum allowed: {maxSizeMB} MB or {MAX_LINES.toLocaleString()} lines
-            </p>
-            <p className="mt-2 text-xs opacity-70">
-              Please download the file to view the full content.
+              {t("codeDisplay.hardLimit.maximum", {
+                maxSizeMB,
+                maxLines: resolvedMaxLines.toLocaleString(),
+              })}
             </p>
             </p>
+            <p className="mt-2 text-xs opacity-70">{t("codeDisplay.hardLimit.hint")}</p>
+            <div className="mt-3">
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                onClick={handleDownload}
+                data-testid="code-display-hard-limit-download"
+              >
+                <Download className="h-4 w-4 mr-2" />
+                {t("codeDisplay.hardLimit.download")}
+              </Button>
+            </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>