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

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