feat: 聊天室对话渲染增强 - 思考内容Markdown渲染 + 工具调用参数卡片化
This commit is contained in:
@@ -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)。
|
||||
|
||||
## 共享代码
|
||||
|
||||
@@ -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 | string>(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 (
|
||||
<pre className="code-block">
|
||||
<code>{codeText}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block">
|
||||
<div className="code-block-header">
|
||||
<span className="code-block-lang">{lang}</span>
|
||||
<Button className="btn-dimmed" icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
|
||||
</div>
|
||||
{highlighted ? (
|
||||
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
) : (
|
||||
<div className="code-block-body">
|
||||
<pre>
|
||||
<code>{codeText}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <HighlightBlock code={codeText} isStreaming={isStreaming} lang={lang} />;
|
||||
}
|
||||
|
||||
function extractCode(children: ReactNode): { codeText: string; lang: string } {
|
||||
|
||||
71
src/web/features/chat/parts/HighlightBlock.tsx
Normal file
71
src/web/features/chat/parts/HighlightBlock.tsx
Normal file
@@ -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 | string>(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 (
|
||||
<pre className="code-block">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block">
|
||||
<div className="code-block-header">
|
||||
<span className="code-block-lang">{lang}</span>
|
||||
<Button className="btn-dimmed" icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
|
||||
</div>
|
||||
{highlighted ? (
|
||||
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
) : (
|
||||
<div className="code-block-body">
|
||||
<pre>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <Typography.Text type="secondary">{text}</Typography.Text>,
|
||||
children: (
|
||||
<div className="reasoning-content">
|
||||
<Markdown
|
||||
options={{
|
||||
optimizeForStreaming: isStreaming,
|
||||
overrides: {
|
||||
pre: { component: CodeBlock, props: { isStreaming } },
|
||||
table: MarkdownTable,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Markdown>
|
||||
</div>
|
||||
),
|
||||
key: REASONING_KEY,
|
||||
label: isStreaming ? (
|
||||
<Flex align="center" component="span" gap={4}>
|
||||
|
||||
@@ -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 (
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
children: <Typography.Text type="danger">{toolPart.errorText}</Typography.Text>,
|
||||
children: (
|
||||
<div className="tool-call-section">
|
||||
<Typography.Text type="danger">错误</Typography.Text>
|
||||
<HighlightBlock code={toolPart.errorText!} isStreaming={false} lang="text" />
|
||||
</div>
|
||||
),
|
||||
key: toolPart.toolCallId ?? toolName,
|
||||
label: (
|
||||
<Flex align="center" component="span" gap={4}>
|
||||
@@ -59,18 +84,18 @@ export function ToolPart({ part }: PartProps) {
|
||||
items={[
|
||||
{
|
||||
children: (
|
||||
<Flex gap={4} vertical>
|
||||
<Flex gap={8} vertical>
|
||||
{toolPart.input != null && (
|
||||
<>
|
||||
<Typography.Text type="secondary">参数:</Typography.Text>
|
||||
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.input)}</pre>
|
||||
</>
|
||||
<div className="tool-call-section">
|
||||
<Typography.Text type="secondary">入参</Typography.Text>
|
||||
<HighlightBlock code={formattedInput} isStreaming={isStreaming} lang={inputLang} />
|
||||
</div>
|
||||
)}
|
||||
{"output" in toolPart && toolPart.output != null && (
|
||||
<>
|
||||
<Typography.Text type="secondary">结果:</Typography.Text>
|
||||
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.output)}</pre>
|
||||
</>
|
||||
{hasOutput && (
|
||||
<div className="tool-call-section">
|
||||
<Typography.Text type="secondary">出参</Typography.Text>
|
||||
<HighlightBlock code={formattedOutput} isStreaming={isStreaming} lang={outputLang} />
|
||||
</div>
|
||||
)}
|
||||
{!toolPart.input && !("output" in toolPart) && (
|
||||
<Typography.Text type="secondary">生成中...</Typography.Text>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
97
tests/web/components/chat/HighlightBlock.test.tsx
Normal file
97
tests/web/components/chat/HighlightBlock.test.tsx
Normal file
@@ -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("流式状态渲染纯 <pre><code> 无高亮", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user