feat(chat): 优化聊天面板交互体验 — 推理折叠/智能滚动/工具中文名/代码块按钮

This commit is contained in:
2026-06-02 08:43:26 +08:00
parent 628b592577
commit 9c9afbd108
10 changed files with 408 additions and 8 deletions

View File

@@ -1,5 +1,5 @@
import { useChat } from "@ai-sdk/react";
import { CopyOutlined, EditOutlined, RedoOutlined, RobotOutlined } from "@ant-design/icons";
import { ArrowDownOutlined, CopyOutlined, EditOutlined, RedoOutlined, RobotOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Flex, Input, Spin, Typography } from "antd";
@@ -17,6 +17,7 @@ import { ChatInputArea } from "./ChatInputArea";
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;
@@ -55,6 +56,8 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const isLoading = status === "submitted" || status === "streaming";
const { isAtBottom, scrollToBottom } = useChatScroll({ messages, scrollRef, status });
useEffect(() => {
if (!conversationId) {
setMessages([]);
@@ -125,10 +128,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
});
}, [conversationId, textModels, projectId, logger]);
useEffect(() => {
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
}, [messages]);
useEffect(() => {
if (status === "ready" && conversationId) {
void queryClient.invalidateQueries({ queryKey: ["conversations", projectId] });
@@ -380,6 +379,15 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
</Flex>
</div>
)}
{!isAtBottom && (
<Button
className="chat-scroll-bottom-btn"
icon={<ArrowDownOutlined />}
onClick={scrollToBottom}
shape="circle"
size="small"
/>
)}
<ChatInputArea
displayModelId={displayModelId}
input={input}

View File

@@ -1,20 +1,40 @@
import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons";
import { Collapse, Flex, Typography } from "antd";
import { useCallback, useMemo, useState } from "react";
import type { PartProps } from "./types";
const REASONING_KEY = "reasoning";
export function ReasoningPart({ part }: PartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
const state = typeof part["state"] === "string" ? part["state"] : "";
const isStreaming = state === "streaming";
const [userToggled, setUserToggled] = useState(false);
const [userActiveKey, setUserActiveKey] = useState<string[]>([]);
const autoActiveKey = useMemo(() => {
if (isStreaming) return [REASONING_KEY];
if (state === "complete" || text.length > 0) return [];
return [];
}, [isStreaming, state, text]);
const activeKey = userToggled ? userActiveKey : autoActiveKey;
const handleChange = useCallback((keys: string | string[]) => {
setUserToggled(true);
setUserActiveKey(keys as string[]);
}, []);
return (
<Collapse
activeKey={activeKey}
ghost
items={[
{
children: <Typography.Text type="secondary">{text}</Typography.Text>,
key: "reasoning",
key: REASONING_KEY,
label: isStreaming ? (
<Flex align="center" component="span" gap={4}>
<LoadingOutlined className="icon-primary" />
@@ -28,6 +48,7 @@ export function ReasoningPart({ part }: PartProps) {
),
},
]}
onChange={handleChange}
size="small"
/>
);

View File

@@ -1,8 +1,25 @@
import type { ComponentType, SVGProps } from "react";
import { CheckOutlined, CopyOutlined, DownloadOutlined } from "@ant-design/icons";
import { Typography } from "antd";
import { Streamdown } from "streamdown";
import type { PartProps } from "./types";
type IconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number }>;
const STREAMDOWN_ICONS: Partial<Record<"CheckIcon" | "CopyIcon" | "DownloadIcon", IconComponent>> = {
CheckIcon: CheckOutlined as IconComponent,
CopyIcon: CopyOutlined as IconComponent,
DownloadIcon: DownloadOutlined as IconComponent,
};
const STREAMDOWN_TRANSLATIONS = {
copied: "已复制",
copyCode: "复制",
downloadFile: "下载",
};
interface TextPartProps extends PartProps {
role: string;
}
@@ -15,7 +32,9 @@ export function TextPart({ part, role }: TextPartProps) {
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<Streamdown parseIncompleteMarkdown>{text}</Streamdown>
<Streamdown icons={STREAMDOWN_ICONS} parseIncompleteMarkdown translations={STREAMDOWN_TRANSLATIONS}>
{text}
</Streamdown>
)}
</div>
);

View File

@@ -8,6 +8,7 @@ interface ToolPartData {
input?: unknown;
output?: unknown;
toolCallId?: string;
toolMetadata?: Record<string, unknown>;
toolName?: string;
type?: string;
}
@@ -24,7 +25,9 @@ const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
export function ToolPart({ part }: PartProps) {
const toolPart = part as unknown as ToolPartData;
const state = getToolState(toolPart);
const toolName = toolPart.toolName ?? (toolPart.type ?? "unknown").replace(/^tool-/, "");
const rawToolName = toolPart.toolName ?? (toolPart.type ?? "unknown").replace(/^tool-/, "");
const toolName =
typeof toolPart.toolMetadata?.["displayName"] === "string" ? toolPart.toolMetadata["displayName"] : rawToolName;
const isStreaming = state === "input-streaming" || state === "input-available";

View File

@@ -0,0 +1,55 @@
import type { UIMessage } from "ai";
import { useCallback, useEffect, useState } from "react";
const NEAR_BOTTOM_THRESHOLD = 80;
interface UseChatScrollOptions {
messages: UIMessage[];
scrollRef: React.RefObject<HTMLDivElement | null>;
status: string;
}
export function useChatScroll({ messages, scrollRef, status }: UseChatScrollOptions) {
const [isAtBottom, setIsAtBottom] = useState(true);
const isStreaming = status === "streaming";
const checkNearBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD;
}, [scrollRef]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
setIsAtBottom(checkNearBottom());
const handleScroll = () => {
setIsAtBottom(checkNearBottom());
};
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [scrollRef, checkNearBottom]);
useEffect(() => {
const el = scrollRef.current;
if (!el || !isAtBottom) return;
el.scrollTo({
behavior: isStreaming ? "instant" : "smooth",
top: el.scrollHeight,
});
}, [messages, isStreaming, isAtBottom, scrollRef]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ behavior: "smooth", top: el.scrollHeight });
setIsAtBottom(true);
}, [scrollRef]);
return { isAtBottom, scrollToBottom };
}