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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user