diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 2b708fe..084a379 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -41,7 +41,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si `ChatPage` = `ConversationSidebar`(自定义组件)+ `ChatPanel`。 - **ConversationSidebar**:会话侧边栏数据加载层(useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除(Popconfirm)。 -- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。 +- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart(markdown-to-jsx 渲染,流式优化)、ToolPart(四态,入参/出参分层卡片展示,通过 HighlightBlock 提供 Shiki 语法高亮和复制按钮)。支持编辑重发、重新生成、复制。 - **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。 ## 共享代码 diff --git a/src/web/features/chat/parts/CodeBlock.tsx b/src/web/features/chat/parts/CodeBlock.tsx index 2558955..3aab219 100644 --- a/src/web/features/chat/parts/CodeBlock.tsx +++ b/src/web/features/chat/parts/CodeBlock.tsx @@ -1,11 +1,6 @@ import type { ReactNode } from "react"; -import { CopyOutlined } from "@ant-design/icons"; -import { App, Button } from "antd"; -import { useCallback, useEffect, useState } from "react"; -import { codeToHtml } from "shiki"; - -import { useIsDark } from "../../../shared/hooks/use-is-dark"; +import { HighlightBlock } from "./HighlightBlock"; interface CodeBlockProps { children: ReactNode; @@ -13,64 +8,9 @@ interface CodeBlockProps { isStreaming: boolean; } -export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) { - const { message } = App.useApp(); - const isDark = useIsDark(); - const [highlighted, setHighlighted] = useState(null); +export function CodeBlock({ children, isStreaming }: CodeBlockProps) { const { codeText, lang } = extractCode(children); - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(codeText).then( - () => message.success("已复制"), - () => message.error("复制失败"), - ); - }, [codeText, message]); - - useEffect(() => { - if (isStreaming || !codeText) return; - - let cancelled = false; - codeToHtml(codeText, { - lang, - theme: isDark ? "github-dark" : "github-light", - }) - .then((html) => { - if (!cancelled) setHighlighted(html); - }) - .catch(() => { - if (!cancelled) setHighlighted(null); - }); - - return () => { - cancelled = true; - }; - }, [codeText, lang, isDark, isStreaming]); - - if (isStreaming) { - return ( -
-        {codeText}
-      
- ); - } - - return ( -
-
- {lang} -
- {highlighted ? ( -
- ) : ( -
-
-            {codeText}
-          
-
- )} -
- ); + return ; } function extractCode(children: ReactNode): { codeText: string; lang: string } { diff --git a/src/web/features/chat/parts/HighlightBlock.tsx b/src/web/features/chat/parts/HighlightBlock.tsx new file mode 100644 index 0000000..5881f3f --- /dev/null +++ b/src/web/features/chat/parts/HighlightBlock.tsx @@ -0,0 +1,71 @@ +import { CopyOutlined } from "@ant-design/icons"; +import { App, Button } from "antd"; +import { useCallback, useEffect, useState } from "react"; +import { codeToHtml } from "shiki"; + +import { useIsDark } from "../../../shared/hooks/use-is-dark"; + +interface HighlightBlockProps { + code: string; + isStreaming: boolean; + lang: string; +} + +export function HighlightBlock({ code, isStreaming, lang }: HighlightBlockProps) { + const { message } = App.useApp(); + const isDark = useIsDark(); + const [highlighted, setHighlighted] = useState(null); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(code).then( + () => message.success("已复制"), + () => message.error("复制失败"), + ); + }, [code, message]); + + useEffect(() => { + if (isStreaming || !code) return; + + let cancelled = false; + codeToHtml(code, { + lang, + theme: isDark ? "github-dark" : "github-light", + }) + .then((html) => { + if (!cancelled) setHighlighted(html); + }) + .catch(() => { + if (!cancelled) setHighlighted(null); + }); + + return () => { + cancelled = true; + }; + }, [code, lang, isDark, isStreaming]); + + if (isStreaming) { + return ( +
+        {code}
+      
+ ); + } + + return ( +
+
+ {lang} +
+ {highlighted ? ( +
+ ) : ( +
+
+            {code}
+          
+
+ )} +
+ ); +} diff --git a/src/web/features/chat/parts/ReasoningPart.tsx b/src/web/features/chat/parts/ReasoningPart.tsx index 6d06116..c842ea4 100644 --- a/src/web/features/chat/parts/ReasoningPart.tsx +++ b/src/web/features/chat/parts/ReasoningPart.tsx @@ -1,7 +1,10 @@ import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons"; import { Collapse, Flex, Typography } from "antd"; +import { Markdown } from "markdown-to-jsx/react"; import { useCallback, useMemo, useState } from "react"; +import { CodeBlock } from "./CodeBlock"; +import { MarkdownTable } from "./MarkdownTable"; import type { PartProps } from "./types"; const REASONING_KEY = "reasoning"; @@ -33,7 +36,21 @@ export function ReasoningPart({ part }: PartProps) { ghost items={[ { - children: {text}, + children: ( +
+ + {text} + +
+ ), key: REASONING_KEY, label: isStreaming ? ( diff --git a/src/web/features/chat/parts/ToolPart.tsx b/src/web/features/chat/parts/ToolPart.tsx index 87db9d0..92bc356 100644 --- a/src/web/features/chat/parts/ToolPart.tsx +++ b/src/web/features/chat/parts/ToolPart.tsx @@ -1,6 +1,7 @@ import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from "@ant-design/icons"; import { Collapse, Flex, Typography } from "antd"; +import { HighlightBlock } from "./HighlightBlock"; import type { PartProps } from "./types"; interface ToolPartData { @@ -20,7 +21,19 @@ function getToolState(part: ToolPartData) { return "input-streaming" as const; } -const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2); +function getInputLang(value: unknown): string { + return typeof value === "object" && value !== null ? "json" : "text"; +} + +function getOutputLang(value: unknown): string { + return typeof value === "object" && value !== null ? "json" : "text"; +} + +function formatContent(value: unknown): string { + if (typeof value === "object" && value !== null) return JSON.stringify(value, null, 2); + if (typeof value === "string") return value; + return String(value); +} export function ToolPart({ part }: PartProps) { const toolPart = part as unknown as ToolPartData; @@ -31,13 +44,25 @@ export function ToolPart({ part }: PartProps) { const isStreaming = state === "input-streaming" || state === "input-available"; + const formattedInput = toolPart.input != null ? formatContent(toolPart.input) : ""; + const inputLang = toolPart.input != null ? getInputLang(toolPart.input) : "text"; + + const hasOutput = "output" in toolPart && toolPart.output != null; + const formattedOutput = hasOutput ? formatContent(toolPart.output) : ""; + const outputLang = hasOutput ? getOutputLang(toolPart.output) : "text"; + if (state === "output-error") { return ( {toolPart.errorText}, + children: ( +
+ 错误 + +
+ ), key: toolPart.toolCallId ?? toolName, label: ( @@ -59,18 +84,18 @@ export function ToolPart({ part }: PartProps) { items={[ { children: ( - + {toolPart.input != null && ( - <> - 参数: -
{FORMAT_JSON(toolPart.input)}
- +
+ 入参 + +
)} - {"output" in toolPart && toolPart.output != null && ( - <> - 结果: -
{FORMAT_JSON(toolPart.output)}
- + {hasOutput && ( +
+ 出参 + +
)} {!toolPart.input && !("output" in toolPart) && ( 生成中... diff --git a/src/web/styles.css b/src/web/styles.css index cc27e99..a29c676 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -243,9 +243,11 @@ body { margin: 4px 0; } -.tool-result-pre { - font-size: 12px; - margin: 0; +/* 工具调用参数 section */ +.tool-call-section { + display: flex; + flex-direction: column; + gap: var(--ant-margin-xxs); } .msg-title-ai { @@ -256,6 +258,11 @@ body { padding: 0 var(--ant-padding-sm); } +.reasoning-content { + font-size: var(--ant-font-size-sm); + color: var(--ant-color-text-secondary); +} + .icon-primary { color: var(--ant-color-primary); } diff --git a/tests/web/components/chat/HighlightBlock.test.tsx b/tests/web/components/chat/HighlightBlock.test.tsx new file mode 100644 index 0000000..750dece --- /dev/null +++ b/tests/web/components/chat/HighlightBlock.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, mock, test } from "bun:test"; +import { screen, waitFor } from "@testing-library/react"; +import { createElement } from "react"; + +import { HighlightBlock } from "../../../../src/web/features/chat/parts/HighlightBlock"; +import { renderWithProviders } from "../../test-utils"; + +describe("HighlightBlock JSON 高亮", () => { + test("非流式状态渲染 shiki 高亮 HTML", async () => { + const code = JSON.stringify({ key: "value" }, null, 2); + + const { container } = renderWithProviders( + createElement(HighlightBlock, { code, isStreaming: false, lang: "json" }), + ); + + expect(screen.getByText("json")).toBeTruthy(); + + await waitFor(() => { + expect(container.querySelector(".code-block-body")).toBeTruthy(); + }); + }); + + test("流式状态渲染纯
 无高亮", () => {
+    const code = JSON.stringify({ key: "value" }, null, 2);
+
+    const { container } = renderWithProviders(createElement(HighlightBlock, { code, isStreaming: true, lang: "json" }));
+
+    const pre = container.querySelector("pre.code-block");
+    expect(pre).toBeTruthy();
+    expect(pre!.textContent).toContain("key");
+    expect(container.querySelector(".code-block-header")).toBeNull();
+  });
+});
+
+describe("HighlightBlock 复制按钮", () => {
+  test("非流式状态显示复制按钮", () => {
+    const code = "const x = 1;";
+
+    renderWithProviders(createElement(HighlightBlock, { code, isStreaming: false, lang: "text" }));
+
+    expect(screen.getByText("text")).toBeTruthy();
+    const buttons = screen.getAllByRole("button");
+    expect(buttons.length).toBeGreaterThan(0);
+  });
+
+  test("复制按钮调用 clipboard.writeText", () => {
+    const code = JSON.stringify({ a: 1 }, null, 2);
+    const writeTextMock = mock(() => Promise.resolve());
+    Object.defineProperty(navigator, "clipboard", {
+      value: { writeText: writeTextMock },
+      writable: true,
+      configurable: true,
+    });
+
+    renderWithProviders(createElement(HighlightBlock, { code, isStreaming: false, lang: "json" }));
+
+    const button = screen.getByRole("button");
+    button.click();
+
+    expect(writeTextMock).toHaveBeenCalledWith(code);
+  });
+});
+
+describe("HighlightBlock 纯文本", () => {
+  test("lang=text 时头部显示 text", () => {
+    renderWithProviders(createElement(HighlightBlock, { code: "hello world", isStreaming: false, lang: "text" }));
+
+    expect(screen.getByText("text")).toBeTruthy();
+  });
+});
+
+describe("HighlightBlock 边界情况", () => {
+  test("code 为空时渲染空代码块不触发异步高亮", () => {
+    const { container } = renderWithProviders(
+      createElement(HighlightBlock, { code: "", isStreaming: false, lang: "json" }),
+    );
+
+    expect(container.querySelector(".code-block")).toBeTruthy();
+    expect(container.querySelector("code")).toBeTruthy();
+    expect(container.querySelector("code")!.textContent).toBe("");
+  });
+
+  test("流式切换到非流式后触发高亮", async () => {
+    const code = JSON.stringify({ x: 1 }, null, 2);
+    const { container, rerender } = renderWithProviders(
+      createElement(HighlightBlock, { code, isStreaming: true, lang: "json" }),
+    );
+
+    expect(container.querySelector("pre.code-block")).toBeTruthy();
+
+    rerender(createElement(HighlightBlock, { code, isStreaming: false, lang: "json" }));
+
+    await waitFor(() => {
+      expect(container.querySelector(".code-block-body")).toBeTruthy();
+    });
+  });
+});
diff --git a/tests/web/components/chat/ReasoningPart.test.tsx b/tests/web/components/chat/ReasoningPart.test.tsx
index 0226dd9..65474a4 100644
--- a/tests/web/components/chat/ReasoningPart.test.tsx
+++ b/tests/web/components/chat/ReasoningPart.test.tsx
@@ -46,4 +46,32 @@ describe("ReasoningPart", () => {
 
     expect(screen.getByText("思考中内容")).toBeTruthy();
   });
+
+  test("Markdown 代码块渲染(流式状态展开)", () => {
+    const part = { state: "streaming", text: "```typescript\nconst x = 1;\n```", type: "reasoning" };
+
+    renderWithProviders(createElement(ReasoningPart, { part }));
+
+    expect(screen.getByText("const x = 1;")).toBeTruthy();
+    expect(document.querySelector(".code-block")).toBeTruthy();
+  });
+
+  test("Markdown 有序列表渲染(流式状态展开)", () => {
+    const part = { state: "streaming", text: "1. 第一项\n2. 第二项", type: "reasoning" };
+
+    const { container } = renderWithProviders(createElement(ReasoningPart, { part }));
+
+    const ol = container.querySelector("ol");
+    expect(ol).toBeTruthy();
+    expect(screen.getByText("第一项")).toBeTruthy();
+  });
+
+  test("Markdown 表格渲染(流式状态展开)", () => {
+    const part = { state: "streaming", text: "| A | B |\n| --- | --- |\n| 1 | 2 |", type: "reasoning" };
+
+    renderWithProviders(createElement(ReasoningPart, { part }));
+
+    expect(screen.getByText("A")).toBeTruthy();
+    expect(document.querySelector(".markdown-table")).toBeTruthy();
+  });
 });
diff --git a/tests/web/components/chat/ToolPart.test.tsx b/tests/web/components/chat/ToolPart.test.tsx
index 114d85a..7682702 100644
--- a/tests/web/components/chat/ToolPart.test.tsx
+++ b/tests/web/components/chat/ToolPart.test.tsx
@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test";
-import { screen } from "@testing-library/react";
+import { fireEvent, screen } from "@testing-library/react";
 import { createElement } from "react";
 
 import { ToolPart } from "../../../../src/web/features/chat/parts/ToolPart";
@@ -63,3 +63,72 @@ describe("ToolPart 工具显示名", () => {
     expect(screen.getByText(/获取当前时间.*失败/)).toBeTruthy();
   });
 });
+
+describe("ToolPart 入参/出参分层展示", () => {
+  test("输入流式状态时面板展开显示入参区块标题", () => {
+    const part = {
+      input: { timezone: "Asia/Shanghai" },
+      toolCallId: "call-stream",
+      toolName: "getCurrentTime",
+      type: "tool-getCurrentTime",
+    };
+
+    renderWithProviders(createElement(ToolPart, { part }));
+
+    const inputLabels = screen.getAllByText("入参");
+    expect(inputLabels.length).toBeGreaterThan(0);
+  });
+
+  test("点击面板展开后显示入参出参区块", () => {
+    const part = {
+      input: { timezone: "Asia/Shanghai" },
+      output: { iso: "2024-01-01T00:00:00.000Z" },
+      toolCallId: "call-1",
+      toolName: "getCurrentTime",
+      type: "tool-getCurrentTime",
+    };
+
+    const { container } = renderWithProviders(createElement(ToolPart, { part }));
+
+    const header = container.querySelector(".ant-collapse-header");
+    expect(header).toBeTruthy();
+    if (header) fireEvent.click(header);
+
+    expect(screen.getByText("入参")).toBeTruthy();
+    expect(screen.getByText("出参")).toBeTruthy();
+  });
+
+  test("输入流式状态时显示生成中文字", () => {
+    const part = {
+      toolCallId: "call-stream",
+      toolName: "getCurrentTime",
+      type: "tool-getCurrentTime",
+    };
+
+    renderWithProviders(createElement(ToolPart, { part }));
+
+    expect(screen.getByText("生成中...")).toBeTruthy();
+  });
+});
+
+describe("ToolPart 错误区块", () => {
+  test("点击错误面板展开后显示错误标题和错误文本(由 HighlightBlock 渲染)", () => {
+    const part = {
+      errorText: "网络超时",
+      toolCallId: "call-err",
+      toolName: "getCurrentTime",
+      type: "tool-getCurrentTime",
+    };
+
+    const { container } = renderWithProviders(createElement(ToolPart, { part }));
+
+    const header = container.querySelector(".ant-collapse-header");
+    expect(header).toBeTruthy();
+    if (header) fireEvent.click(header);
+
+    expect(screen.getByText("错误")).toBeTruthy();
+    expect(screen.getByText("网络超时")).toBeTruthy();
+    // 错误文本在 HighlightBlock 中渲染,带 code-block 结构
+    expect(container.querySelector(".code-block")).toBeTruthy();
+  });
+});