feat(chat): 引入 @ant-design/x 组件 — Sender/Conversations/XMarkdown 替代手动拼装

- ConfigProvider → XProvider(ConsoleShell + test-utils)
- ChatSidebar → Conversations(menu dropdown + MoreOutlined trigger)
- ChatInputArea → Sender(footer 左右排版 + 模型 Select + 自动清空)
- Streamdown → XMarkdown(streaming hasNextChunk 映射 AI SDK 状态)
- CSS 清理 ~94 行废弃样式,新增统一布局规则
- 删除 streamdown 依赖
This commit is contained in:
2026-06-02 18:31:31 +08:00
parent 9c9afbd108
commit 26ecaadb26
13 changed files with 289 additions and 561 deletions

View File

@@ -1,68 +0,0 @@
import { Button, Flex, Input, Select } from "antd";
interface ChatInputAreaProps {
displayModelId: null | string;
input: string;
isLoading: boolean;
modelOptions: Array<{ label: string; value: string }>;
onInputChange: (value: string) => void;
onModelChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
}
export function ChatInputArea({
displayModelId,
input,
isLoading,
modelOptions,
onInputChange,
onModelChange,
onSend,
onStop,
}: ChatInputAreaProps) {
return (
<div className="chat-input-area">
<Input.TextArea
autoSize={{ maxRows: 6, minRows: 1 }}
className="chat-textarea"
disabled={isLoading}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void onSend();
}
}}
placeholder="输入消息..."
value={input}
/>
<Flex align="center" gap={8} justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={onModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center" gap={8}>
{isLoading ? (
<Button
danger
onClick={() => {
onStop?.();
}}
>
</Button>
) : (
<Button onClick={() => void onSend()} type="primary">
</Button>
)}
</Flex>
</Flex>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useChat } from "@ai-sdk/react";
import { ArrowDownOutlined, CopyOutlined, EditOutlined, RedoOutlined, RobotOutlined } from "@ant-design/icons";
import { Sender } from "@ant-design/x";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Flex, Input, Spin, Typography } from "antd";
import { App, Button, Card, Divider, Flex, Input, Select, Spin, Typography } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -13,7 +14,6 @@ import {
} from "../../../../hooks/use-conversations";
import { useLogger } from "../../../../hooks/use-logger";
import { useModelList } from "../../../../hooks/use-models";
import { ChatInputArea } from "./ChatInputArea";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
import { ToolPart } from "./parts/ToolPart";
@@ -149,42 +149,37 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
[projectId, conversationId, logger],
);
const handleSend = useCallback(async () => {
if (!input.trim()) return;
const text = input;
setInput("");
const handleSenderSubmit = useCallback(
(msg: string) => {
if (!msg.trim()) return;
if (!conversationId) {
try {
const conv = await createConversation(projectId, displayModelId ?? undefined);
skipHistoryLoadRef.current = conv.id;
void queryClient.invalidateQueries({ queryKey: ["conversations", projectId] });
void sendMessage({ text }, { body: { conversationId: conv.id } });
onConversationCreated(conv.id);
} catch (err: unknown) {
setInput(text);
const msg = err instanceof Error ? err.message : String(err);
logger.error("创建会话失败", { error: msg, projectId });
void message.error(`创建会话失败:${msg}`);
if (!conversationId) {
void (async () => {
try {
const conv = await createConversation(projectId, displayModelId ?? undefined);
skipHistoryLoadRef.current = conv.id;
void queryClient.invalidateQueries({ queryKey: ["conversations", projectId] });
void sendMessage({ text: msg }, { body: { conversationId: conv.id } });
setInput("");
onConversationCreated(conv.id);
} catch (err: unknown) {
setInput(msg);
const errMsg = err instanceof Error ? err.message : String(err);
logger.error("创建会话失败", { error: errMsg, projectId });
void message.error(`创建会话失败:${errMsg}`);
}
})();
return;
}
return;
}
void sendMessage({ text }, { body: { conversationId } }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.error("发送消息失败", { conversationId, error: msg, projectId });
});
}, [
input,
sendMessage,
conversationId,
projectId,
onConversationCreated,
message,
queryClient,
displayModelId,
logger,
]);
setInput("");
void sendMessage({ text: msg }, { body: { conversationId } }).catch((err: unknown) => {
const errMsg = err instanceof Error ? err.message : String(err);
logger.error("发送消息失败", { conversationId, error: errMsg, projectId });
});
},
[sendMessage, conversationId, projectId, onConversationCreated, message, queryClient, displayModelId, logger],
);
const extractText = useCallback((msg: UIMessage) => {
return msg.parts
@@ -308,23 +303,40 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Typography.Text type="secondary"></Typography.Text>
</Flex>
</div>
<ChatInputArea
displayModelId={displayModelId}
input={input}
isLoading={isLoading}
modelOptions={modelOptions}
onInputChange={setInput}
onModelChange={handleModelChange}
onSend={() => {
void handleSend();
}}
onStop={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
/>
<div className="chat-sender-area">
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
)}
loading={isLoading}
onCancel={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
suffix={false}
value={input}
/>
</div>
</div>
);
}
@@ -365,7 +377,12 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
</Flex>
) : (
msg.parts.map((part: Record<string, unknown>, i: number) => (
<PartRenderer key={i} part={part} role={msg.role} />
<PartRenderer
isStreaming={isLoading && idx === messages.length - 1 && msg.role === "assistant"}
key={i}
part={part}
role={msg.role}
/>
))
)}
</div>
@@ -388,32 +405,57 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
size="small"
/>
)}
<ChatInputArea
displayModelId={displayModelId}
input={input}
isLoading={isLoading}
modelOptions={modelOptions}
onInputChange={setInput}
onModelChange={handleModelChange}
onSend={() => {
void handleSend();
}}
onStop={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
/>
<div className="chat-sender-area">
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
)}
loading={isLoading}
onCancel={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
suffix={false}
value={input}
/>
</div>
</div>
);
}
function PartRenderer({ part, role }: { part: Record<string, unknown>; role: string }) {
function PartRenderer({
isStreaming,
part,
role,
}: {
isStreaming: boolean;
part: Record<string, unknown>;
role: string;
}) {
const partType = typeof part["type"] === "string" ? part["type"] : "";
if (partType === "text") {
return <TextPart part={part} role={role} />;
return <TextPart isStreaming={isStreaming} part={part} role={role} />;
}
if (partType.startsWith("tool-")) {
return <ToolPart part={part} />;

View File

@@ -1,102 +0,0 @@
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Button, Flex, Popconfirm, Spin, Typography } from "antd";
import type { Conversation } from "../../../../../shared/api";
import { createConversation, deleteConversation, fetchConversations } from "../../../../hooks/use-conversations";
import { useModelList } from "../../../../hooks/use-models";
interface ChatSidebarProps {
activeId: null | string;
onSelect: (id: null | string) => void;
projectId: string;
}
export function ChatSidebar({ activeId, onSelect, projectId }: ChatSidebarProps) {
const queryClient = useQueryClient();
const { message } = App.useApp();
const CONVERSATIONS_KEY = ["conversations", projectId] as const;
const { data, isLoading } = useQuery({
queryFn: () => fetchConversations(projectId),
queryKey: CONVERSATIONS_KEY,
});
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text"));
const defaultModelId = textModels[0]?.id;
const createMutation = useMutation({
mutationFn: () => createConversation(projectId, defaultModelId),
onError: (err: Error) => {
void message.error(`创建会话失败:${err.message}`);
},
onSuccess: (conversation: Conversation) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
onSelect(conversation.id);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteConversation(projectId, id),
onError: (err: Error) => {
void message.error(`删除会话失败:${err.message}`);
},
onSuccess: (_data: void, id: string) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
if (activeId === id) onSelect(null);
},
});
const conversations = data?.items ?? [];
return (
<div className="app-chat-sidebar">
<div className="app-chat-sidebar-header">
<Button
block
icon={<PlusOutlined />}
loading={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="primary"
>
</Button>
</div>
<div className="app-chat-sidebar-list">
{isLoading ? (
<div className="app-chat-sidebar-loading">
<Spin />
</div>
) : (
conversations.map((item: Conversation) => (
<Flex
align="center"
className={`app-chat-sidebar-item ${activeId === item.id ? "app-chat-sidebar-item-active" : ""}`}
gap="small"
justify="space-between"
key={item.id}
onClick={() => onSelect(item.id)}
>
<Typography.Text className="app-chat-sidebar-item-title" ellipsis>
{item.title}
</Typography.Text>
<Popconfirm onConfirm={() => deleteMutation.mutate(item.id)} title="确定删除此会话?">
<Button
className="app-chat-sidebar-item-action"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
size="small"
type="text"
/>
</Popconfirm>
</Flex>
))
)}
</div>
</div>
);
}

View File

@@ -1,30 +1,14 @@
import type { ComponentType, SVGProps } from "react";
import { CheckOutlined, CopyOutlined, DownloadOutlined } from "@ant-design/icons";
import { XMarkdown } from "@ant-design/x-markdown";
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 {
isStreaming: boolean;
role: string;
}
export function TextPart({ part, role }: TextPartProps) {
export function TextPart({ isStreaming, part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
return (
@@ -32,9 +16,7 @@ export function TextPart({ part, role }: TextPartProps) {
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<Streamdown icons={STREAMDOWN_ICONS} parseIncompleteMarkdown translations={STREAMDOWN_TRANSLATIONS}>
{text}
</Streamdown>
<XMarkdown content={text} streaming={{ hasNextChunk: isStreaming }} />
)}
</div>
);

View File

@@ -1,22 +1,93 @@
import { Flex } from "antd";
import { DeleteOutlined, MoreOutlined } from "@ant-design/icons";
import { Conversations } from "@ant-design/x";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Spin } from "antd";
import { useState } from "react";
import type { Conversation } from "../../../../shared/api";
import { createConversation, deleteConversation, fetchConversations } from "../../../hooks/use-conversations";
import { useModelList } from "../../../hooks/use-models";
import { ChatPanel } from "../components/chat/ChatPanel";
import { ChatSidebar } from "../components/chat/ChatSidebar";
import { useCurrentProject } from "../useCurrentProject";
export function ChatPage() {
const project = useCurrentProject();
const [activeConversationId, setActiveConversationId] = useState<null | string>(null);
const queryClient = useQueryClient();
const { message } = App.useApp();
const CONVERSATIONS_KEY = ["conversations", project.id] as const;
const { data, isLoading } = useQuery({
queryFn: () => fetchConversations(project.id),
queryKey: CONVERSATIONS_KEY,
});
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text"));
const defaultModelId = textModels[0]?.id;
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteConversation(project.id, id),
onError: (err: Error) => {
void message.error(`删除会话失败:${err.message}`);
},
onSuccess: (_data: void, id: string) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
if (activeConversationId === id) setActiveConversationId(null);
},
});
const conversations = (data?.items ?? []).map((c: Conversation) => ({
key: c.id,
label: c.title,
}));
return (
<Flex className="app-chat-page" gap={0} vertical={false}>
<ChatSidebar activeId={activeConversationId} onSelect={setActiveConversationId} projectId={project.id} />
<div className="app-chat-page">
<div className="app-chat-conversations">
{isLoading ? (
<Spin />
) : (
<Conversations
activeKey={activeConversationId ?? ""}
creation={{
onClick: () => {
void createConversation(project.id, defaultModelId)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
},
}}
items={conversations}
menu={(conv) => ({
items: [
{
danger: true,
icon: <DeleteOutlined />,
key: "delete",
label: "删除",
onClick: () => {
deleteMutation.mutate(conv.key);
},
},
],
trigger: <MoreOutlined />,
})}
onActiveChange={(key) => setActiveConversationId(key)}
/>
)}
</div>
<ChatPanel
conversationId={activeConversationId}
onConversationCreated={setActiveConversationId}
projectId={project.id}
/>
</Flex>
</div>
);
}