diff --git a/src/server/ai/tools/get-current-time.ts b/src/server/ai/tools/get-current-time.ts
index 4077a8f..34a20e8 100644
--- a/src/server/ai/tools/get-current-time.ts
+++ b/src/server/ai/tools/get-current-time.ts
@@ -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: "获取当前时间" },
});
}
diff --git a/src/web/consoles/workbench/components/chat/ChatPanel.tsx b/src/web/consoles/workbench/components/chat/ChatPanel.tsx
index 9f47098..3b815fa 100644
--- a/src/web/consoles/workbench/components/chat/ChatPanel.tsx
+++ b/src/web/consoles/workbench/components/chat/ChatPanel.tsx
@@ -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 }:
)}
+ {!isAtBottom && (
+ }
+ onClick={scrollToBottom}
+ shape="circle"
+ size="small"
+ />
+ )}
([]);
+
+ 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 (
{text},
- key: "reasoning",
+ key: REASONING_KEY,
label: isStreaming ? (
@@ -28,6 +48,7 @@ export function ReasoningPart({ part }: PartProps) {
),
},
]}
+ onChange={handleChange}
size="small"
/>
);
diff --git a/src/web/consoles/workbench/components/chat/parts/TextPart.tsx b/src/web/consoles/workbench/components/chat/parts/TextPart.tsx
index 7c703a4..320de35 100644
--- a/src/web/consoles/workbench/components/chat/parts/TextPart.tsx
+++ b/src/web/consoles/workbench/components/chat/parts/TextPart.tsx
@@ -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 & { size?: number }>;
+
+const STREAMDOWN_ICONS: Partial> = {
+ 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" ? (
{text}
) : (
- {text}
+
+ {text}
+
)}
);
diff --git a/src/web/consoles/workbench/components/chat/parts/ToolPart.tsx b/src/web/consoles/workbench/components/chat/parts/ToolPart.tsx
index a5c760c..87db9d0 100644
--- a/src/web/consoles/workbench/components/chat/parts/ToolPart.tsx
+++ b/src/web/consoles/workbench/components/chat/parts/ToolPart.tsx
@@ -8,6 +8,7 @@ interface ToolPartData {
input?: unknown;
output?: unknown;
toolCallId?: string;
+ toolMetadata?: Record;
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";
diff --git a/src/web/consoles/workbench/components/chat/use-chat-scroll.ts b/src/web/consoles/workbench/components/chat/use-chat-scroll.ts
new file mode 100644
index 0000000..570a6ce
--- /dev/null
+++ b/src/web/consoles/workbench/components/chat/use-chat-scroll.ts
@@ -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;
+ 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 };
+}
diff --git a/src/web/styles.css b/src/web/styles.css
index e30dfca..3fd6bb8 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -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;
}
diff --git a/tests/web/components/chat/ReasoningPart.test.tsx b/tests/web/components/chat/ReasoningPart.test.tsx
new file mode 100644
index 0000000..9ddba2e
--- /dev/null
+++ b/tests/web/components/chat/ReasoningPart.test.tsx
@@ -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();
+ });
+});
diff --git a/tests/web/components/chat/ToolPart.test.tsx b/tests/web/components/chat/ToolPart.test.tsx
new file mode 100644
index 0000000..245a6c2
--- /dev/null
+++ b/tests/web/components/chat/ToolPart.test.tsx
@@ -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();
+ });
+});
diff --git a/tests/web/hooks/use-chat-scroll.test.ts b/tests/web/hooks/use-chat-scroll.test.ts
new file mode 100644
index 0000000..73779a4
--- /dev/null
+++ b/tests/web/hooks/use-chat-scroll.test.ts
@@ -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;
+ clientHeight: number;
+ removeEventListener: ReturnType;
+ scrollHeight: number;
+ scrollTo: ReturnType;
+ scrollTop: number;
+}
+
+function asHTMLElement(el: MockedElement): HTMLElement {
+ return el as unknown as HTMLElement;
+}
+
+function asRef(el: MockedElement): React.RefObject {
+ return { current: asHTMLElement(el) };
+}
+
+function createScrollElement(overrides: Partial = {}): 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;
+ 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;
+ 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 | undefined;
+ return opts?.["behavior"] === "instant";
+ });
+ expect(instantCall).toBeTruthy();
+ });
+});