feat: 工作台聊天室功能

This commit is contained in:
2026-05-31 02:37:23 +08:00
parent 83cf9eab94
commit f83f434863
33 changed files with 2520 additions and 265 deletions

View File

@@ -0,0 +1,132 @@
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 { useCallback, useEffect, useRef, useState } from "react";
import { fetchMessages } from "../../../../hooks/use-conversations";
import { MessageBubble } from "./MessageBubble";
interface ChatPanelProps {
conversationId: null | string;
onConversationCreated: (id: string) => void;
projectId: string;
}
export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
const { message } = App.useApp();
const [input, setInput] = useState("");
const [loadingHistory, setLoadingHistory] = useState(false);
const fetchRef = useRef(fetchMessages);
const conversationIdRef = useRef(conversationId);
useEffect(() => {
conversationIdRef.current = conversationId;
});
const { messages, sendMessage, setMessages, status } = useChat({
onError: (err) => {
void message.error(`发送失败:${err.message}`);
},
transport: new DefaultChatTransport({
api: `/api/projects/${projectId}/chat`,
}),
});
const isLoading = status === "submitted" || status === "streaming";
useEffect(() => {
if (!conversationId) {
setMessages([]);
return;
}
let cancelled = false;
const load = async () => {
setLoadingHistory(true);
setMessages([]);
try {
const data = await fetchRef.current(projectId, conversationId);
if (cancelled) return;
const history = data.items
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
.reverse()
.map((m: { content: string; id: string; role: string }) => ({
id: m.id,
parts: [{ text: m.content, type: "text" as const }],
role: m.role as "assistant" | "user",
}));
setMessages(history);
} catch (err: unknown) {
if (!cancelled) {
const msg = err instanceof Error ? err.message : String(err);
void message.error(`加载历史失败:${msg}`);
}
} finally {
if (!cancelled) setLoadingHistory(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [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],
);
if (!conversationId) {
return (
<div className="app-chat-panel app-chat-panel-empty">
<Empty description="选择或创建一个会话开始聊天" />
</div>
);
}
return (
<div className="app-chat-panel">
{loadingHistory ? (
<div className="app-chat-panel-loading">
<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="app-chat-panel-sender">
<Sender loading={isLoading} onChange={setInput} onSubmit={onSubmit} placeholder="输入消息..." value={input} />
</div>
</div>
);
}