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

@@ -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();
});
});

View 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();
});
});

View 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();
});
});