feat(chat): 聊天滚动条美化 + Markdown 增强 — OverlayScrollbars/CodeHighlighter/代码复制/表格样式
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user