feat(chat): 优化聊天面板交互体验 — 推理折叠/智能滚动/工具中文名/代码块按钮
This commit is contained in:
@@ -10,6 +10,7 @@ export function createGetCurrentTime(logger?: Logger) {
|
||||
inputSchema: z.object({
|
||||
timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"),
|
||||
}),
|
||||
metadata: { displayName: "获取当前时间" },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -141,6 +141,7 @@ body {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-welcome-area {
|
||||
@@ -251,6 +252,47 @@ body {
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.chat-scroll-bottom-btn {
|
||||
position: absolute;
|
||||
bottom: 115px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--ant-border-radius-sm);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
background: var(--ant-color-bg-container);
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] button:hover {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.app-page-flex {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
49
tests/web/components/chat/ReasoningPart.test.tsx
Normal file
49
tests/web/components/chat/ReasoningPart.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { ReasoningPart } from "../../../../src/web/consoles/workbench/components/chat/parts/ReasoningPart";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("ReasoningPart", () => {
|
||||
test("流式状态时自动展开显示文本", () => {
|
||||
const part = { state: "streaming", text: "正在思考...", type: "reasoning" };
|
||||
|
||||
renderWithProviders(createElement(ReasoningPart, { part }));
|
||||
|
||||
expect(screen.getByText("正在思考...")).toBeTruthy();
|
||||
expect(screen.getByText("思考中")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("完成状态时显示思考完成标签", () => {
|
||||
const part = { state: "complete", text: "思考内容", type: "reasoning" };
|
||||
|
||||
renderWithProviders(createElement(ReasoningPart, { part }));
|
||||
|
||||
expect(screen.getByText("思考完成")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("无状态时默认收起", () => {
|
||||
const part = { text: "默认内容", type: "reasoning" };
|
||||
|
||||
const { container } = renderWithProviders(createElement(ReasoningPart, { part }));
|
||||
|
||||
const expanded = container.querySelector('[aria-expanded="true"]');
|
||||
expect(expanded).toBeNull();
|
||||
});
|
||||
|
||||
test("用户点击折叠后尊重用户意图", () => {
|
||||
const part = { state: "streaming", text: "思考中内容", type: "reasoning" };
|
||||
|
||||
const { container } = renderWithProviders(createElement(ReasoningPart, { part }));
|
||||
|
||||
const collapseHeader = container.querySelector(".ant-collapse-header");
|
||||
expect(collapseHeader).toBeTruthy();
|
||||
|
||||
if (collapseHeader) {
|
||||
fireEvent.click(collapseHeader);
|
||||
}
|
||||
|
||||
expect(screen.getByText("思考中内容")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
65
tests/web/components/chat/ToolPart.test.tsx
Normal file
65
tests/web/components/chat/ToolPart.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { ToolPart } from "../../../../src/web/consoles/workbench/components/chat/parts/ToolPart";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("ToolPart 工具显示名", () => {
|
||||
test("无 toolMetadata 时使用 toolName", () => {
|
||||
const part = {
|
||||
input: { timezone: "Asia/Shanghai" },
|
||||
output: { iso: "2024-01-01T00:00:00.000Z", local: "2024年1月1日", timestamp: 1704067200000 },
|
||||
toolCallId: "call-1",
|
||||
toolName: "getCurrentTime",
|
||||
type: "tool-getCurrentTime",
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
expect(screen.getByText(/getCurrentTime/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("有 toolMetadata.displayName 时优先使用显示名", () => {
|
||||
const part = {
|
||||
input: { timezone: "Asia/Shanghai" },
|
||||
output: { iso: "2024-01-01T00:00:00.000Z", local: "2024年1月1日", timestamp: 1704067200000 },
|
||||
toolCallId: "call-1",
|
||||
toolMetadata: { displayName: "获取当前时间" },
|
||||
toolName: "getCurrentTime",
|
||||
type: "tool-getCurrentTime",
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
expect(screen.getByText("获取当前时间")).toBeTruthy();
|
||||
expect(screen.queryByText(/getCurrentTime/)).toBeNull();
|
||||
});
|
||||
|
||||
test("toolMetadata.displayName 非字符串时回退到 toolName", () => {
|
||||
const part = {
|
||||
input: {},
|
||||
output: {},
|
||||
toolCallId: "call-2",
|
||||
toolMetadata: { displayName: 123 },
|
||||
type: "tool-someTool",
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
expect(screen.getByText("someTool")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("错误状态时使用显示名", () => {
|
||||
const part = {
|
||||
errorText: "超时",
|
||||
toolCallId: "call-3",
|
||||
toolMetadata: { displayName: "获取当前时间" },
|
||||
type: "tool-getCurrentTime",
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
expect(screen.getByText(/获取当前时间.*失败/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
137
tests/web/hooks/use-chat-scroll.test.ts
Normal file
137
tests/web/hooks/use-chat-scroll.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import { useChatScroll } from "../../../src/web/consoles/workbench/components/chat/use-chat-scroll";
|
||||
|
||||
interface MockedElement {
|
||||
addEventListener: ReturnType<typeof mock>;
|
||||
clientHeight: number;
|
||||
removeEventListener: ReturnType<typeof mock>;
|
||||
scrollHeight: number;
|
||||
scrollTo: ReturnType<typeof mock>;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
function asHTMLElement(el: MockedElement): HTMLElement {
|
||||
return el as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
function asRef(el: MockedElement): React.RefObject<HTMLElement | null> {
|
||||
return { current: asHTMLElement(el) };
|
||||
}
|
||||
|
||||
function createScrollElement(overrides: Partial<MockedElement> = {}): MockedElement {
|
||||
return {
|
||||
addEventListener: mock(() => {}),
|
||||
clientHeight: 400,
|
||||
removeEventListener: mock(() => {}),
|
||||
scrollHeight: 1000,
|
||||
scrollTo: mock(() => {}),
|
||||
scrollTop: 600,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChatScroll", () => {
|
||||
test("在底部附近时自动滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
||||
const scrollRef = asRef(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 }),
|
||||
{
|
||||
initialProps: { messages, scrollRef, status: "streaming" },
|
||||
},
|
||||
);
|
||||
|
||||
const newMessages: UIMessage[] = [
|
||||
{ id: "1", parts: [], role: "user" },
|
||||
{ id: "2", parts: [], role: "assistant" },
|
||||
] as UIMessage[];
|
||||
|
||||
rerender({ messages: newMessages, scrollRef, status: "streaming" });
|
||||
|
||||
expect(el.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("不在底部时不自动滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
||||
const scrollRef = asRef(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 }),
|
||||
{
|
||||
initialProps: { messages, scrollRef, status: "streaming" },
|
||||
},
|
||||
);
|
||||
|
||||
const handlerCalls = el.addEventListener.mock.calls as unknown[][];
|
||||
const scrollHandler = handlerCalls.find((c) => c[0] === "scroll")?.[1];
|
||||
|
||||
if (scrollHandler) {
|
||||
el.scrollTop = 200;
|
||||
(scrollHandler as () => void)();
|
||||
}
|
||||
|
||||
el.scrollTo.mockClear();
|
||||
|
||||
const newMessages: UIMessage[] = [
|
||||
{ id: "1", parts: [], role: "user" },
|
||||
{ id: "2", parts: [], role: "assistant" },
|
||||
] as UIMessage[];
|
||||
|
||||
rerender({ messages: newMessages, scrollRef, status: "streaming" });
|
||||
|
||||
const scrollToCalls = el.scrollTo.mock.calls as unknown[][];
|
||||
expect(scrollToCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("scrollToBottom 使用 smooth 滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
||||
const scrollRef = asRef(el);
|
||||
const messages: UIMessage[] = [];
|
||||
|
||||
const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready" }));
|
||||
|
||||
act(() => {
|
||||
result.current.scrollToBottom();
|
||||
});
|
||||
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "smooth", top: 1000 });
|
||||
});
|
||||
|
||||
test("流式时使用 instant 滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
||||
const scrollRef = asRef(el);
|
||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||
|
||||
renderHook(() => useChatScroll({ messages, scrollRef, status: "streaming" }));
|
||||
|
||||
const calls = el.scrollTo.mock.calls as unknown[][];
|
||||
const instantCall = calls.find((call) => {
|
||||
const opts = call[0] as Record<string, unknown> | undefined;
|
||||
return opts?.["behavior"] === "instant";
|
||||
});
|
||||
expect(instantCall).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user