feat(chat): 聊天滚动条美化 + Markdown 增强 — OverlayScrollbars/CodeHighlighter/代码复制/表格样式
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -19,6 +19,8 @@
|
|||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"es-toolkit": "^1.47.0",
|
"es-toolkit": "^1.47.0",
|
||||||
|
"overlayscrollbars": "^2.16.0",
|
||||||
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"pino-roll": "^4.0.0",
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"es-toolkit": "^1.47.0",
|
"es-toolkit": "^1.47.0",
|
||||||
|
"overlayscrollbars": "^2.16.0",
|
||||||
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"pino-roll": "^4.0.0",
|
"pino-roll": "^4.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useChat } from "@ai-sdk/react";
|
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 { Sender } from "@ant-design/x";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||||
@@ -14,10 +14,10 @@ import {
|
|||||||
} from "../../../../hooks/use-conversations";
|
} from "../../../../hooks/use-conversations";
|
||||||
import { useLogger } from "../../../../hooks/use-logger";
|
import { useLogger } from "../../../../hooks/use-logger";
|
||||||
import { useModelList } from "../../../../hooks/use-models";
|
import { useModelList } from "../../../../hooks/use-models";
|
||||||
|
import { ChatScrollArea } from "./ChatScrollArea";
|
||||||
import { ReasoningPart } from "./parts/ReasoningPart";
|
import { ReasoningPart } from "./parts/ReasoningPart";
|
||||||
import { TextPart } from "./parts/TextPart";
|
import { TextPart } from "./parts/TextPart";
|
||||||
import { ToolPart } from "./parts/ToolPart";
|
import { ToolPart } from "./parts/ToolPart";
|
||||||
import { useChatScroll } from "./use-chat-scroll";
|
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
conversationId: null | string;
|
conversationId: null | string;
|
||||||
@@ -35,7 +35,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
|
|||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
|
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
|
||||||
const fetchRef = useRef(fetchMessages);
|
const fetchRef = useRef(fetchMessages);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const skipHistoryLoadRef = useRef<null | string>(null);
|
const skipHistoryLoadRef = useRef<null | string>(null);
|
||||||
|
|
||||||
const { data: modelsData } = useModelList({ pageSize: 200 });
|
const { data: modelsData } = useModelList({ pageSize: 200 });
|
||||||
@@ -56,8 +55,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
|
|||||||
|
|
||||||
const isLoading = status === "submitted" || status === "streaming";
|
const isLoading = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
const { isAtBottom, scrollToBottom } = useChatScroll({ messages, scrollRef, status });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@@ -348,7 +345,7 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
|
|||||||
<Spin />
|
<Spin />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="chat-scroll-area" ref={scrollRef}>
|
<ChatScrollArea messages={messages} status={status}>
|
||||||
<Flex gap={8} vertical>
|
<Flex gap={8} vertical>
|
||||||
{messages.map((msg, idx) => (
|
{messages.map((msg, idx) => (
|
||||||
<Card
|
<Card
|
||||||
@@ -394,16 +391,7 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</ChatScrollArea>
|
||||||
)}
|
|
||||||
{!isAtBottom && (
|
|
||||||
<Button
|
|
||||||
className="chat-scroll-bottom-btn"
|
|
||||||
icon={<ArrowDownOutlined />}
|
|
||||||
onClick={scrollToBottom}
|
|
||||||
shape="circle"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div className="chat-sender-area">
|
<div className="chat-sender-area">
|
||||||
<Sender
|
<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 { XMarkdown } from "@ant-design/x-markdown";
|
||||||
|
import "@ant-design/x-markdown/themes/light.css";
|
||||||
import { Typography } from "antd";
|
import { Typography } from "antd";
|
||||||
|
|
||||||
import type { PartProps } from "./types";
|
import type { PartProps } from "./types";
|
||||||
|
|
||||||
|
import { CodeBlockWithCopy } from "./CodeBlockWithCopy";
|
||||||
|
|
||||||
interface TextPartProps extends PartProps {
|
interface TextPartProps extends PartProps {
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const xmarkdownComponents = {
|
||||||
|
code: CodeBlockWithCopy,
|
||||||
|
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||||
|
};
|
||||||
|
|
||||||
export function TextPart({ isStreaming, part, role }: TextPartProps) {
|
export function TextPart({ isStreaming, part, role }: TextPartProps) {
|
||||||
const text = typeof part["text"] === "string" ? part["text"] : "";
|
const text = typeof part["text"] === "string" ? part["text"] : "";
|
||||||
|
|
||||||
@@ -16,7 +24,12 @@ export function TextPart({ isStreaming, part, role }: TextPartProps) {
|
|||||||
{role === "user" ? (
|
{role === "user" ? (
|
||||||
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD = 80;
|
const NEAR_BOTTOM_THRESHOLD = 80;
|
||||||
|
|
||||||
interface UseChatScrollOptions {
|
interface UseChatScrollOptions {
|
||||||
|
loadingHistory?: boolean;
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
status: string;
|
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 [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const isStreaming = status === "streaming";
|
const isStreaming = status === "streaming";
|
||||||
|
const prevLoadingRef = useRef(loadingHistory ?? false);
|
||||||
|
|
||||||
const checkNearBottom = useCallback(() => {
|
const checkNearBottom = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -21,33 +24,45 @@ export function useChatScroll({ messages, scrollRef, status }: UseChatScrollOpti
|
|||||||
}, [scrollRef]);
|
}, [scrollRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = viewportElement;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
setIsAtBottom(checkNearBottom());
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
setIsAtBottom(checkNearBottom());
|
setIsAtBottom(checkNearBottom());
|
||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => el.removeEventListener("scroll", handleScroll);
|
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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el || !isAtBottom) return;
|
if (!el || !isAtBottom) return;
|
||||||
|
|
||||||
el.scrollTo({
|
el.scrollTo({ behavior: "instant", top: el.scrollHeight });
|
||||||
behavior: isStreaming ? "instant" : "smooth",
|
|
||||||
top: el.scrollHeight,
|
|
||||||
});
|
|
||||||
}, [messages, isStreaming, isAtBottom, scrollRef]);
|
}, [messages, isStreaming, isAtBottom, scrollRef]);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollTo({ behavior: "smooth", top: el.scrollHeight });
|
el.scrollTo({ behavior: "instant", top: el.scrollHeight });
|
||||||
setIsAtBottom(true);
|
setIsAtBottom(true);
|
||||||
}, [scrollRef]);
|
}, [scrollRef]);
|
||||||
|
|
||||||
|
|||||||
@@ -124,22 +124,13 @@ body {
|
|||||||
margin-top: 8px;
|
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 {
|
.chat-scroll-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
|
||||||
overflow-anchor: auto;
|
overflow-anchor: auto;
|
||||||
margin-left: var(--ant-padding-sm);
|
margin-left: var(--ant-padding-sm);
|
||||||
border-radius: var(--ant-border-radius-lg);
|
border-radius: var(--ant-border-radius-lg);
|
||||||
scroll-behavior: smooth;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-loading-indicator {
|
.chat-loading-indicator {
|
||||||
@@ -219,3 +210,38 @@ body {
|
|||||||
.app-page-flex {
|
.app-page-flex {
|
||||||
flex: 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
99
tests/web/components/chat/CodeBlockWithCopy.test.tsx
Normal file
99
tests/web/components/chat/CodeBlockWithCopy.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,14 @@ import { describe, expect, mock, test } from "bun:test";
|
|||||||
|
|
||||||
import { useChatScroll } from "../../../src/web/consoles/workbench/components/chat/use-chat-scroll";
|
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 {
|
interface MockedElement {
|
||||||
addEventListener: ReturnType<typeof mock>;
|
addEventListener: ReturnType<typeof mock>;
|
||||||
clientHeight: number;
|
clientHeight: number;
|
||||||
@@ -14,12 +22,12 @@ interface MockedElement {
|
|||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asHTMLElement(el: MockedElement): HTMLElement {
|
function asRef(el: MockedElement): React.RefObject<HTMLDivElement | null> {
|
||||||
return el as unknown as HTMLElement;
|
return { current: el as unknown as HTMLDivElement };
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRef(el: MockedElement): React.RefObject<HTMLElement | null> {
|
function asViewport(el: MockedElement): HTMLDivElement {
|
||||||
return { current: asHTMLElement(el) };
|
return el as unknown as HTMLDivElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createScrollElement(overrides: Partial<MockedElement> = {}): MockedElement {
|
function createScrollElement(overrides: Partial<MockedElement> = {}): MockedElement {
|
||||||
@@ -38,20 +46,14 @@ describe("useChatScroll", () => {
|
|||||||
test("在底部附近时自动滚动", () => {
|
test("在底部附近时自动滚动", () => {
|
||||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
||||||
const scrollRef = asRef(el);
|
const scrollRef = asRef(el);
|
||||||
|
const viewportElement = asViewport(el);
|
||||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||||
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({
|
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
|
||||||
messages,
|
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
|
||||||
scrollRef,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
messages: UIMessage[];
|
|
||||||
scrollRef: React.RefObject<HTMLElement | null>;
|
|
||||||
status: string;
|
|
||||||
}) => useChatScroll({ messages, scrollRef, status }),
|
|
||||||
{
|
{
|
||||||
initialProps: { messages, scrollRef, status: "streaming" },
|
initialProps: { loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ describe("useChatScroll", () => {
|
|||||||
{ id: "2", parts: [], role: "assistant" },
|
{ id: "2", parts: [], role: "assistant" },
|
||||||
] as UIMessage[];
|
] as UIMessage[];
|
||||||
|
|
||||||
rerender({ messages: newMessages, scrollRef, status: "streaming" });
|
rerender({ loadingHistory: false, messages: newMessages, scrollRef, status: "streaming", viewportElement });
|
||||||
|
|
||||||
expect(el.scrollTo).toHaveBeenCalled();
|
expect(el.scrollTo).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -68,20 +70,14 @@ describe("useChatScroll", () => {
|
|||||||
test("不在底部时不自动滚动", () => {
|
test("不在底部时不自动滚动", () => {
|
||||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
||||||
const scrollRef = asRef(el);
|
const scrollRef = asRef(el);
|
||||||
|
const viewportElement = asViewport(el);
|
||||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||||
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({
|
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
|
||||||
messages,
|
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
|
||||||
scrollRef,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
messages: UIMessage[];
|
|
||||||
scrollRef: React.RefObject<HTMLElement | null>;
|
|
||||||
status: string;
|
|
||||||
}) => useChatScroll({ messages, scrollRef, status }),
|
|
||||||
{
|
{
|
||||||
initialProps: { messages, scrollRef, status: "streaming" },
|
initialProps: { loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,32 +96,36 @@ describe("useChatScroll", () => {
|
|||||||
{ id: "2", parts: [], role: "assistant" },
|
{ id: "2", parts: [], role: "assistant" },
|
||||||
] as UIMessage[];
|
] 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[][];
|
const scrollToCalls = el.scrollTo.mock.calls as unknown[][];
|
||||||
expect(scrollToCalls.length).toBe(0);
|
expect(scrollToCalls.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("scrollToBottom 使用 smooth 滚动", () => {
|
test("scrollToBottom 使用 instant 滚动", () => {
|
||||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
||||||
const scrollRef = asRef(el);
|
const scrollRef = asRef(el);
|
||||||
|
const viewportElement = asViewport(el);
|
||||||
const messages: UIMessage[] = [];
|
const messages: UIMessage[] = [];
|
||||||
|
|
||||||
const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready" }));
|
const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready", viewportElement }));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.scrollToBottom();
|
result.current.scrollToBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "smooth", top: 1000 });
|
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("流式时使用 instant 滚动", () => {
|
test("流式时使用 instant 滚动", () => {
|
||||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
||||||
const scrollRef = asRef(el);
|
const scrollRef = asRef(el);
|
||||||
|
const viewportElement = asViewport(el);
|
||||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
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 calls = el.scrollTo.mock.calls as unknown[][];
|
||||||
const instantCall = calls.find((call) => {
|
const instantCall = calls.find((call) => {
|
||||||
@@ -134,4 +134,57 @@ describe("useChatScroll", () => {
|
|||||||
});
|
});
|
||||||
expect(instantCall).toBeTruthy();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user