feat(chat): 聊天滚动条美化 + Markdown 增强 — OverlayScrollbars/CodeHighlighter/代码复制/表格样式

This commit is contained in:
2026-06-02 21:19:50 +08:00
parent 26ecaadb26
commit ed97b30d51
10 changed files with 385 additions and 68 deletions

View File

@@ -19,6 +19,8 @@
"antd": "^6.4.3",
"drizzle-orm": "^0.45.2",
"es-toolkit": "^1.47.0",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pino-roll": "^4.0.0",
@@ -1259,6 +1261,10 @@
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"overlayscrollbars": ["overlayscrollbars@2.16.0", "", {}, "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg=="],
"overlayscrollbars-react": ["overlayscrollbars-react@0.5.6", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "react": ">=16.8.0" } }, "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw=="],
"own-keys": ["own-keys@1.0.1", "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],

View File

@@ -67,6 +67,8 @@
"antd": "^6.4.3",
"drizzle-orm": "^0.45.2",
"es-toolkit": "^1.47.0",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pino-roll": "^4.0.0",

View File

@@ -1,5 +1,5 @@
import { useChat } from "@ai-sdk/react";
import { ArrowDownOutlined, CopyOutlined, EditOutlined, RedoOutlined, RobotOutlined } from "@ant-design/icons";
import { CopyOutlined, EditOutlined, RedoOutlined, RobotOutlined } from "@ant-design/icons";
import { Sender } from "@ant-design/x";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai";
@@ -14,10 +14,10 @@ import {
} from "../../../../hooks/use-conversations";
import { useLogger } from "../../../../hooks/use-logger";
import { useModelList } from "../../../../hooks/use-models";
import { ChatScrollArea } from "./ChatScrollArea";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
import { ToolPart } from "./parts/ToolPart";
import { useChatScroll } from "./use-chat-scroll";
interface ChatPanelProps {
conversationId: null | string;
@@ -35,7 +35,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const [loadingHistory, setLoadingHistory] = useState(false);
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
const fetchRef = useRef(fetchMessages);
const scrollRef = useRef<HTMLDivElement>(null);
const skipHistoryLoadRef = useRef<null | string>(null);
const { data: modelsData } = useModelList({ pageSize: 200 });
@@ -56,8 +55,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const isLoading = status === "submitted" || status === "streaming";
const { isAtBottom, scrollToBottom } = useChatScroll({ messages, scrollRef, status });
useEffect(() => {
if (!conversationId) {
setMessages([]);
@@ -348,7 +345,7 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Spin />
</div>
) : (
<div className="chat-scroll-area" ref={scrollRef}>
<ChatScrollArea messages={messages} status={status}>
<Flex gap={8} vertical>
{messages.map((msg, idx) => (
<Card
@@ -394,16 +391,7 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
</Flex>
)}
</Flex>
</div>
)}
{!isAtBottom && (
<Button
className="chat-scroll-bottom-btn"
icon={<ArrowDownOutlined />}
onClick={scrollToBottom}
shape="circle"
size="small"
/>
</ChatScrollArea>
)}
<div className="chat-sender-area">
<Sender

View File

@@ -0,0 +1,58 @@
import type { UIMessage } from "ai";
import { ArrowDownOutlined } from "@ant-design/icons";
import { Button } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { useCallback, useRef, useState } from "react";
import { useChatScroll } from "./use-chat-scroll";
interface ChatScrollAreaProps {
children: React.ReactNode;
messages: UIMessage[];
status: string;
}
export function ChatScrollArea({ children, messages, status }: ChatScrollAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
const handleOsInitialized = useCallback(() => {
const os = osRef.current;
if (!os) return;
const instance = os.osInstance();
if (!instance) return;
const viewport = instance.elements().viewport as HTMLDivElement;
scrollRef.current = viewport;
setViewportElement(viewport);
}, []);
const { isAtBottom, scrollToBottom } = useChatScroll({ messages, scrollRef, status, viewportElement });
return (
<>
<OverlayScrollbarsComponent
className="chat-scroll-area"
events={{ initialized: handleOsInitialized }}
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
ref={osRef}
>
{children}
</OverlayScrollbarsComponent>
{!isAtBottom && (
<Button
className="chat-scroll-bottom-btn"
icon={<ArrowDownOutlined />}
onClick={scrollToBottom}
shape="circle"
size="small"
/>
)}
</>
);
}

View File

@@ -0,0 +1,57 @@
import { CopyOutlined } from "@ant-design/icons";
import { CodeHighlighter } from "@ant-design/x";
import { App, Button, Flex, Typography } from "antd";
import React from "react";
interface CodeBlockWithCopyProps {
block?: boolean;
children?: React.ReactNode;
className?: string;
lang?: string;
streamStatus?: "done" | "loading";
}
export function CodeBlockWithCopy({ block, children, className, lang }: CodeBlockWithCopyProps) {
const { message } = App.useApp();
if (!block) {
return <code className={className}>{children}</code>;
}
const codeText = extractText(children);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const displayLang = lang || "plaintext";
const handleCopy = () => {
void navigator.clipboard.writeText(codeText).then(() => {
void message.success("已复制");
});
};
const header = (
<Flex align="center" justify="space-between" style={{ padding: "0 4px" }}>
<Typography.Text style={{ color: "var(--ant-color-text-quaternary)", fontSize: 12 }}>
{displayLang}
</Typography.Text>
<Button
icon={<CopyOutlined />}
onClick={handleCopy}
size="small"
style={{ color: "var(--ant-color-text-quaternary)" }}
type="text"
/>
</Flex>
);
return (
<CodeHighlighter header={header} lang={displayLang}>
{codeText}
</CodeHighlighter>
);
}
function extractText(children: React.ReactNode): string {
return React.Children.toArray(children)
.map((child) => (typeof child === "string" ? child : ""))
.join("");
}

View File

@@ -1,13 +1,21 @@
import { XMarkdown } from "@ant-design/x-markdown";
import "@ant-design/x-markdown/themes/light.css";
import { Typography } from "antd";
import type { PartProps } from "./types";
import { CodeBlockWithCopy } from "./CodeBlockWithCopy";
interface TextPartProps extends PartProps {
isStreaming: boolean;
role: string;
}
const xmarkdownComponents = {
code: CodeBlockWithCopy,
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
};
export function TextPart({ isStreaming, part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
@@ -16,7 +24,12 @@ export function TextPart({ isStreaming, part, role }: TextPartProps) {
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<XMarkdown content={text} streaming={{ hasNextChunk: isStreaming }} />
<XMarkdown
className="x-markdown-light"
components={xmarkdownComponents}
content={text}
streaming={{ hasNextChunk: isStreaming }}
/>
)}
</div>
);

View File

@@ -1,18 +1,21 @@
import type { UIMessage } from "ai";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
const NEAR_BOTTOM_THRESHOLD = 80;
interface UseChatScrollOptions {
loadingHistory?: boolean;
messages: UIMessage[];
scrollRef: React.RefObject<HTMLDivElement | null>;
status: string;
viewportElement: HTMLDivElement | null;
}
export function useChatScroll({ messages, scrollRef, status }: UseChatScrollOptions) {
export function useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }: UseChatScrollOptions) {
const [isAtBottom, setIsAtBottom] = useState(true);
const isStreaming = status === "streaming";
const prevLoadingRef = useRef(loadingHistory ?? false);
const checkNearBottom = useCallback(() => {
const el = scrollRef.current;
@@ -21,33 +24,45 @@ export function useChatScroll({ messages, scrollRef, status }: UseChatScrollOpti
}, [scrollRef]);
useEffect(() => {
const el = scrollRef.current;
const el = viewportElement;
if (!el) return;
setIsAtBottom(checkNearBottom());
const handleScroll = () => {
setIsAtBottom(checkNearBottom());
};
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [scrollRef, checkNearBottom]);
}, [viewportElement, checkNearBottom]);
useEffect(() => {
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = loadingHistory ?? false;
if (wasLoading && !loadingHistory) {
const el = scrollRef.current;
if (!el) return;
requestAnimationFrame(() => {
const target = scrollRef.current;
if (!target) return;
target.scrollTo({ behavior: "instant", top: target.scrollHeight });
setIsAtBottom(true);
});
return;
}
}, [loadingHistory, scrollRef]);
useEffect(() => {
const el = scrollRef.current;
if (!el || !isAtBottom) return;
el.scrollTo({
behavior: isStreaming ? "instant" : "smooth",
top: el.scrollHeight,
});
el.scrollTo({ behavior: "instant", top: el.scrollHeight });
}, [messages, isStreaming, isAtBottom, scrollRef]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ behavior: "smooth", top: el.scrollHeight });
el.scrollTo({ behavior: "instant", top: el.scrollHeight });
setIsAtBottom(true);
}, [scrollRef]);

View File

@@ -124,22 +124,13 @@ body {
margin-top: 8px;
}
.message-body pre {
background: var(--ant-color-bg-layout);
padding: 8px 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
}
.chat-scroll-area {
flex: 1;
min-height: 0;
overflow: auto;
overflow-anchor: auto;
margin-left: var(--ant-padding-sm);
border-radius: var(--ant-border-radius-lg);
scroll-behavior: smooth;
height: 100%;
}
.chat-loading-indicator {
@@ -219,3 +210,38 @@ body {
.app-page-flex {
flex: 1;
}
.os-theme-custom {
--os-size: 8px;
--os-padding-perpendicular: 2px;
--os-padding-axis: 2px;
--os-track-border-radius: 10px;
--os-handle-border-radius: 10px;
--os-handle-bg: rgba(0, 0, 0, 0.15);
--os-handle-bg-hover: rgba(0, 0, 0, 0.25);
--os-handle-bg-active: rgba(0, 0, 0, 0.35);
--os-handle-min-size: 33px;
--os-handle-max-size: none;
--os-handle-interactive-area-offset: 4px;
}
.x-markdown-light table {
border-collapse: collapse;
width: 100%;
}
.x-markdown-light th,
.x-markdown-light td {
border: 1px solid var(--ant-color-border);
padding: 6px 12px;
text-align: left;
}
.x-markdown-light th {
background: var(--ant-color-fill-quaternary);
font-weight: 600;
}
.x-markdown-light .x-md-table-wrap {
overflow-x: auto;
}

View File

@@ -0,0 +1,99 @@
import { screen } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import { CodeBlockWithCopy } from "../../../../src/web/consoles/workbench/components/chat/parts/CodeBlockWithCopy";
import { renderWithProviders } from "../../test-utils";
const mockWriteText = mock(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => ({ writeText: mockWriteText }),
});
describe("CodeBlockWithCopy", () => {
test("block 模式渲染 CodeHighlighter 和语言标签", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "const x = 1;",
lang: "typescript",
streamStatus: "done",
}),
);
expect(screen.getByText("typescript")).toBeTruthy();
});
test("block 模式渲染复制按钮", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "hello world",
lang: "python",
streamStatus: "done",
}),
);
const copyBtn = screen.getByRole("button");
expect(copyBtn).toBeTruthy();
});
test("block 模式语言为空时显示 plaintext", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "some code",
lang: "",
streamStatus: "done",
}),
);
expect(screen.getByText("plaintext")).toBeTruthy();
});
test("block 模式语言为 undefined 时显示 plaintext", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "some code",
streamStatus: "done",
}),
);
expect(screen.getByText("plaintext")).toBeTruthy();
});
test("inline 模式返回 code 元素", () => {
const { container } = renderWithProviders(
createElement(CodeBlockWithCopy, {
block: false,
children: "inline code",
className: "language-ts",
}),
);
const code = container.querySelector("code.language-ts");
expect(code).toBeTruthy();
expect(code?.textContent).toBe("inline code");
});
test("点击复制按钮调用 clipboard.writeText", () => {
mockWriteText.mockClear();
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "copy me",
lang: "javascript",
streamStatus: "done",
}),
);
const copyBtn = screen.getByRole("button");
copyBtn.click();
expect(mockWriteText).toHaveBeenCalledWith("copy me");
});
});

View File

@@ -5,6 +5,14 @@ import { describe, expect, mock, test } from "bun:test";
import { useChatScroll } from "../../../src/web/consoles/workbench/components/chat/use-chat-scroll";
interface HookProps {
loadingHistory: boolean;
messages: UIMessage[];
scrollRef: React.RefObject<HTMLDivElement | null>;
status: string;
viewportElement: HTMLDivElement | null;
}
interface MockedElement {
addEventListener: ReturnType<typeof mock>;
clientHeight: number;
@@ -14,12 +22,12 @@ interface MockedElement {
scrollTop: number;
}
function asHTMLElement(el: MockedElement): HTMLElement {
return el as unknown as HTMLElement;
function asRef(el: MockedElement): React.RefObject<HTMLDivElement | null> {
return { current: el as unknown as HTMLDivElement };
}
function asRef(el: MockedElement): React.RefObject<HTMLElement | null> {
return { current: asHTMLElement(el) };
function asViewport(el: MockedElement): HTMLDivElement {
return el as unknown as HTMLDivElement;
}
function createScrollElement(overrides: Partial<MockedElement> = {}): MockedElement {
@@ -38,20 +46,14 @@ describe("useChatScroll", () => {
test("在底部附近时自动滚动", () => {
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
const scrollRef = asRef(el);
const viewportElement = asViewport(el);
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
const { rerender } = renderHook(
({
messages,
scrollRef,
status,
}: {
messages: UIMessage[];
scrollRef: React.RefObject<HTMLElement | null>;
status: string;
}) => useChatScroll({ messages, scrollRef, status }),
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
{
initialProps: { messages, scrollRef, status: "streaming" },
initialProps: { loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement },
},
);
@@ -60,7 +62,7 @@ describe("useChatScroll", () => {
{ id: "2", parts: [], role: "assistant" },
] as UIMessage[];
rerender({ messages: newMessages, scrollRef, status: "streaming" });
rerender({ loadingHistory: false, messages: newMessages, scrollRef, status: "streaming", viewportElement });
expect(el.scrollTo).toHaveBeenCalled();
});
@@ -68,20 +70,14 @@ describe("useChatScroll", () => {
test("不在底部时不自动滚动", () => {
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
const scrollRef = asRef(el);
const viewportElement = asViewport(el);
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
const { rerender } = renderHook(
({
messages,
scrollRef,
status,
}: {
messages: UIMessage[];
scrollRef: React.RefObject<HTMLElement | null>;
status: string;
}) => useChatScroll({ messages, scrollRef, status }),
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
{
initialProps: { messages, scrollRef, status: "streaming" },
initialProps: { loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement },
},
);
@@ -100,32 +96,36 @@ describe("useChatScroll", () => {
{ id: "2", parts: [], role: "assistant" },
] as UIMessage[];
rerender({ messages: newMessages, scrollRef, status: "streaming" });
rerender({ loadingHistory: false, messages: newMessages, scrollRef, status: "streaming", viewportElement });
const scrollToCalls = el.scrollTo.mock.calls as unknown[][];
expect(scrollToCalls.length).toBe(0);
});
test("scrollToBottom 使用 smooth 滚动", () => {
test("scrollToBottom 使用 instant 滚动", () => {
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
const scrollRef = asRef(el);
const viewportElement = asViewport(el);
const messages: UIMessage[] = [];
const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready" }));
const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready", viewportElement }));
act(() => {
result.current.scrollToBottom();
});
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "smooth", top: 1000 });
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 1000 });
});
test("流式时使用 instant 滚动", () => {
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
const scrollRef = asRef(el);
const viewportElement = asViewport(el);
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
renderHook(() => useChatScroll({ messages, scrollRef, status: "streaming" }));
renderHook(() =>
useChatScroll({ loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement }),
);
const calls = el.scrollTo.mock.calls as unknown[][];
const instantCall = calls.find((call) => {
@@ -134,4 +134,57 @@ describe("useChatScroll", () => {
});
expect(instantCall).toBeTruthy();
});
test("loadingHistory 从 true 变为 false 时强制 scrollToBottom", async () => {
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 0 });
const scrollRef = asRef(el);
const viewportElement = asViewport(el);
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
const { rerender } = renderHook(
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
{
initialProps: { loadingHistory: true, messages, scrollRef, status: "ready", viewportElement },
},
);
el.scrollTo.mockClear();
act(() => {
rerender({ loadingHistory: false, messages, scrollRef, status: "ready", viewportElement });
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: el.scrollHeight });
});
test("loadingHistory 不变时不触发强制滚动", () => {
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
const scrollRef = asRef(el);
const viewportElement = asViewport(el);
const messages: UIMessage[] = [];
const { rerender } = renderHook(
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
{
initialProps: { loadingHistory: false, messages, scrollRef, status: "ready", viewportElement },
},
);
el.scrollTo.mockClear();
const newMessages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
rerender({ loadingHistory: false, messages: newMessages, scrollRef, status: "ready", viewportElement });
const instantCalls = el.scrollTo.mock.calls.filter((call: unknown[]) => {
const opts = call[0] as Record<string, unknown> | undefined;
return opts?.["behavior"] === "instant" && opts?.["top"] === el.scrollHeight;
});
expect(instantCalls.length).toBe(1);
});
});