refactor: 聊天室 Agent 重构 — ToolLoopAgent + 论坛式布局

后端:
- 删除 agent-stream.ts,新建 alfred-agent.ts (ToolLoopAgent 工厂)
- 新建 get-current-time.ts 工具 (zod schema)
- 重构 send.ts: createAgentUIStreamResponse + onFinish 可靠持久化

前端:
- 删除 MessageBubble.tsx,新建 ToolCallCard.tsx (四态)
- 重构 ChatPanel.tsx: 论坛式 Card 布局 + PartRenderer 分派
- 移除 @ant-design/x 依赖,改用 antd 组件 + streamdown

依赖:
+ zod + streamdown
- @ant-design/x - @ant-design/x-markdown

测试: 306 pass, typecheck/lint 0 errors
This commit is contained in:
2026-05-31 17:25:29 +08:00
parent f83f434863
commit 6eeb4ced7b
16 changed files with 698 additions and 322 deletions

View File

@@ -1,17 +1,14 @@
import type { BubbleItemType } from "@ant-design/x";
import { useChat } from "@ai-sdk/react";
import { Bubble, Sender } from "@ant-design/x";
import { DefaultChatTransport } from "ai";
import { App, Empty, Spin } from "antd";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Avatar, Button, Card, Collapse, Divider, Empty, Flex, Input, Spin, Typography } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { fetchMessages } from "../../../../hooks/use-conversations";
import { MessageBubble } from "./MessageBubble";
import { ToolCallCard } from "./ToolCallCard";
interface ChatPanelProps {
conversationId: null | string;
onConversationCreated: (id: string) => void;
projectId: string;
}
@@ -20,23 +17,21 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
const [input, setInput] = useState("");
const [loadingHistory, setLoadingHistory] = useState(false);
const fetchRef = useRef(fetchMessages);
const scrollRef = useRef<HTMLDivElement>(null);
const conversationIdRef = useRef(conversationId);
useEffect(() => {
conversationIdRef.current = conversationId;
});
const { messages, sendMessage, setMessages, status } = useChat({
const { messages, sendMessage, setMessages, status, stop } = useChat({
onError: (err) => {
void message.error(`发送失败:${err.message}`);
},
transport: new DefaultChatTransport({
api: `/api/projects/${projectId}/chat`,
}),
transport: new DefaultChatTransport({ api: `/api/projects/${projectId}/chat` }),
});
const isLoading = status === "submitted" || status === "streaming";
useEffect(() => {
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
}, [messages]);
useEffect(() => {
if (!conversationId) {
setMessages([]);
@@ -47,6 +42,7 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
const load = async () => {
setLoadingHistory(true);
setInput("");
setMessages([]);
try {
const data = await fetchRef.current(projectId, conversationId);
@@ -54,9 +50,9 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
const history = data.items
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
.reverse()
.map((m: { content: string; id: string; role: string }) => ({
.map((m: { content: string; id: string; parts: null | string; role: string }) => ({
id: m.id,
parts: [{ text: m.content, type: "text" as const }],
parts: m.parts ? (JSON.parse(m.parts) as UIMessage["parts"]) : [{ text: m.content, type: "text" as const }],
role: m.role as "assistant" | "user",
}));
setMessages(history);
@@ -77,23 +73,12 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
};
}, [conversationId, projectId, setMessages, message]);
const bubbleItems: BubbleItemType[] = messages.map((msg) => ({
content: msg.parts
.filter((p): p is { text: string; type: "text" } => p.type === "text")
.map((p) => p.text)
.join(""),
key: msg.id,
role: msg.role === "user" ? "user" : "ai",
}));
const onSubmit = useCallback(
(nextInput: string) => {
if (!nextInput.trim()) return;
setInput("");
void sendMessage({ text: nextInput }, { body: { conversationId: conversationIdRef.current } });
},
[sendMessage],
);
const handleSend = useCallback(() => {
if (!input.trim() || !conversationId) return;
const text = input;
setInput("");
void sendMessage({ text }, { body: { conversationId } });
}, [input, sendMessage, conversationId]);
if (!conversationId) {
return (
@@ -110,23 +95,96 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
<Spin />
</div>
) : (
<Bubble.List
items={bubbleItems}
role={{
ai: {
contentRender: (content: string) => <MessageBubble content={content} />,
placement: "start",
},
user: {
placement: "end",
},
}}
style={{ flex: 1, overflow: "auto", padding: "16px" }}
/>
<div className="chat-scroll-area" ref={scrollRef}>
<Flex gap={8} vertical>
{messages.map((msg) => (
<Card className={msg.role === "user" ? "msg-user" : "msg-ai"} key={msg.id} size="small">
<Card.Meta
avatar={
<Avatar className={msg.role === "assistant" ? "avatar-ai" : undefined} size="small">
{msg.role === "user" ? "你" : "AI"}
</Avatar>
}
title={msg.role === "user" ? "你" : "Alfred"}
/>
<div className="message-body">
{msg.parts.map((part: Record<string, unknown>, i: number) => (
<PartRenderer key={i} part={part} role={msg.role} />
))}
</div>
</Card>
))}
{isLoading && (
<Flex className="chat-loading-indicator" justify="center">
<Spin size="small" />
</Flex>
)}
</Flex>
</div>
)}
<div className="app-chat-panel-sender">
<Sender loading={isLoading} onChange={setInput} onSubmit={onSubmit} placeholder="输入消息..." value={input} />
</div>
<Flex align="end" className="chat-input-area" gap={8}>
<Input.TextArea
autoSize={{ maxRows: 6, minRows: 1 }}
className="chat-textarea"
disabled={isLoading}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="输入消息..."
value={input}
/>
{isLoading ? (
<Button
danger
onClick={() => {
void stop?.();
}}
>
</Button>
) : (
<Button onClick={handleSend} type="primary">
</Button>
)}
</Flex>
</div>
);
}
function PartRenderer({ part, role }: { part: Record<string, unknown>; role: string }) {
const partType = typeof part["type"] === "string" ? part["type"] : "";
if (partType === "text" && role === "user") {
return <Typography.Paragraph className="message-body-text">{part["text"] as string}</Typography.Paragraph>;
}
if (partType === "text" && role === "assistant") {
return <Streamdown parseIncompleteMarkdown>{part["text"] as string}</Streamdown>;
}
if (partType.startsWith("tool-")) {
return <ToolCallCard part={part} />;
}
if (partType === "reasoning") {
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="secondary">{part["text"] as string}</Typography.Text>,
key: "reasoning",
label: <Typography.Text type="secondary"></Typography.Text>,
},
]}
size="small"
/>
);
}
if (partType === "step-start") {
return <Divider className="step-divider" />;
}
return null;
}

View File

@@ -1,7 +0,0 @@
interface MessageBubbleProps {
content: string;
}
export function MessageBubble({ content }: MessageBubbleProps) {
return <div className="app-chat-message-bubble">{content}</div>;
}

View File

@@ -0,0 +1,70 @@
import { Collapse, Flex, Tag, Typography } from "antd";
interface ToolPart {
errorText?: string;
input?: unknown;
output?: unknown;
toolCallId?: string;
toolName?: string;
type?: string;
}
function getToolState(part: ToolPart) {
if ("errorText" in part && part.errorText) return "output-error" as const;
if ("output" in part) return "output-available" as const;
if ("input" in part) return "input-available" as const;
return "input-streaming" as const;
}
const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
interface ToolCallCardProps {
part: ToolPart;
}
export function ToolCallCard({ part }: ToolCallCardProps) {
const state = getToolState(part);
const toolName = part.toolName ?? (part.type ?? "unknown").replace(/^tool-/, "");
switch (state) {
case "input-available":
return <Tag color="processing">{toolName} · </Tag>;
case "input-streaming":
return <Tag color="processing">...</Tag>;
case "output-available":
return (
<Collapse
ghost
items={[
{
children: (
<Flex gap={4} vertical>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(part.input)}</pre>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(part.output)}</pre>
</Flex>
),
key: part.toolCallId ?? toolName,
label: (
<>
<Tag color="success"></Tag> {toolName}
</>
),
},
]}
size="small"
/>
);
case "output-error":
return (
<Flex align="center" gap={4}>
<Tag color="error"></Tag>
<Typography.Text type="danger">{part.errorText}</Typography.Text>
</Flex>
);
}
}

View File

@@ -12,11 +12,7 @@ export function ChatPage() {
return (
<Flex className="app-chat-page" gap={0} vertical={false}>
<ChatSidebar activeId={activeConversationId} onSelect={setActiveConversationId} projectId={project.id} />
<ChatPanel
conversationId={activeConversationId}
onConversationCreated={setActiveConversationId}
projectId={project.id}
/>
<ChatPanel conversationId={activeConversationId} projectId={project.id} />
</Flex>
);
}