feat: 聊天室对话渲染增强 - 思考内容Markdown渲染 + 工具调用参数卡片化

This commit is contained in:
2026-06-07 18:27:41 +08:00
parent 1d82f4f961
commit a389888eb4
9 changed files with 335 additions and 81 deletions

View File

@@ -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 } {

View 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>
);
}

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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);
}