Files
Alfred/src/web/features/chat/ChatPanel.tsx
lanyuanxiaoyao 5b09a16bc3 refactor(web): React 最佳实践优化 — memo/callback + 目录边界 + 路由增强
- useLogger: useMemo + JSON.stringify 替代 useState 派生
- useIsDark: effectiveTheme 替代 token 色值比较
- useCurrentProject: layouts/ 提升到 shared/hooks/
- ConsoleShell: locale useMemo 缓存
- ConsoleOutlet: 添加 Suspense 边界
- routes: 添加 layout 级 errorElement
- Table 组件: operationColumn useMemo + useCallback
- ChatPanel: footer 合并为 useCallback, props 传入模型数据
- ChatPage: textModels/conversations useMemo 缓存
2026-06-03 11:32:28 +08:00

444 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useChat } from "@ai-sdk/react";
import { 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, Divider, Flex, Input, Select, Spin, Typography } from "antd";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createConversation,
fetchConversation,
fetchMessages,
updateConversation,
} from "../../shared/hooks/use-conversations";
import { useLogger } from "../../shared/hooks/use-logger";
import { ChatScrollArea } from "./ChatScrollArea";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps {
conversationId: null | string;
defaultModelId: null | string;
onConversationCreated: (id: string) => void;
projectId: string;
textModels: Array<{ id: string; name: string }>;
}
export function ChatPanel({
conversationId,
defaultModelId: _defaultModelId,
onConversationCreated,
projectId,
textModels,
}: ChatPanelProps) {
const { message } = App.useApp();
const logger = useLogger({ component: "ChatPanel", page: "workbench" });
const queryClient = useQueryClient();
const [input, setInput] = useState("");
const [editingMessageId, setEditingMessageId] = useState<null | string>(null);
const [editText, setEditText] = useState("");
const [loadingHistory, setLoadingHistory] = useState(false);
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
const fetchRef = useRef(fetchMessages);
const skipHistoryLoadRef = useRef<null | string>(null);
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({
onError: (err) => {
logger.error("聊天发送失败", { error: err.message });
void message.error(`发送失败:${err.message}`);
},
transport: new DefaultChatTransport({ api: `/api/projects/${projectId}/chat` }),
});
const isLoading = status === "submitted" || status === "streaming";
useEffect(() => {
if (!conversationId) {
setMessages([]);
return;
}
if (skipHistoryLoadRef.current === conversationId) {
skipHistoryLoadRef.current = null;
return;
}
let cancelled = false;
const load = async () => {
setLoadingHistory(true);
setInput("");
setMessages([]);
try {
const msgPromise = fetchRef.current(projectId, conversationId);
const data = await msgPromise;
if (cancelled) return;
const history = data.items
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
.reverse()
.map((m: { content: string; id: string; parts: null | string; role: string }) => ({
id: m.id,
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);
} catch (err: unknown) {
if (!cancelled) {
const msg = err instanceof Error ? err.message : String(err);
logger.error("加载历史失败", { conversationId, error: msg, projectId });
void message.error(`加载历史失败:${msg}`);
}
} finally {
if (!cancelled) setLoadingHistory(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [conversationId, projectId, setMessages, message, logger]);
useEffect(() => {
if (!conversationId) return;
const firstTextId = textModels[0]?.id;
if (!firstTextId) return;
void fetchConversation(projectId, conversationId)
.then((conv) => {
if (textModels.every((m) => m.id !== conv.modelId)) {
setSelectedModelId(firstTextId);
void updateConversation(projectId, conversationId, { modelId: firstTextId });
} else {
setSelectedModelId(conv.modelId);
}
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("获取会话模型信息失败", { conversationId, error: msg, projectId });
});
}, [conversationId, textModels, projectId, logger]);
useEffect(() => {
if (status === "ready" && conversationId) {
void queryClient.invalidateQueries({ queryKey: ["conversations", projectId] });
}
}, [status, conversationId, projectId, queryClient]);
const displayModelId = conversationId ? selectedModelId : (textModels[0]?.id ?? null);
const handleModelChange = useCallback(
(value: string) => {
setSelectedModelId(value);
if (conversationId) {
void updateConversation(projectId, conversationId, { modelId: value }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("更新会话模型失败", { conversationId, error: msg, projectId });
});
}
},
[projectId, conversationId, logger],
);
const handleSenderSubmit = useCallback(
(msg: string) => {
if (!msg.trim()) return;
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;
}
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 renderSenderFooter = useCallback(
(actionNode: ReactNode) => (
<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>
),
[isLoading, handleModelChange, modelOptions, displayModelId],
);
const handleStop = useCallback(() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}, [stop, logger]);
const extractText = useCallback((msg: UIMessage) => {
return msg.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string; type: "text" }).text)
.join("");
}, []);
const handleCopy = useCallback(
(msg: UIMessage) => {
const text = extractText(msg);
void navigator.clipboard
.writeText(text)
.then(() => {
void message.success("已复制");
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("复制失败", { error: msg });
});
},
[extractText, message, logger],
);
const handleEditStart = useCallback(
(msg: UIMessage) => {
setEditingMessageId(msg.id);
setEditText(extractText(msg));
},
[extractText],
);
const handleEditConfirm = useCallback(() => {
if (!editText.trim() || !conversationId) return;
setEditingMessageId(null);
const idx = messages.findIndex((m) => m.id === editingMessageId);
if (idx === -1) return;
setMessages(messages.slice(0, idx));
void sendMessage({ text: editText }, { body: { conversationId } }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.error("重新发送消息失败", { conversationId, error: msg, projectId });
});
}, [editText, conversationId, messages, editingMessageId, setMessages, sendMessage, logger, projectId]);
const handleEditCancel = useCallback(() => {
setEditingMessageId(null);
setEditText("");
}, []);
const handleRegenerate = useCallback(() => {
if (!conversationId) return;
void regenerate({ body: { conversationId } }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.error("重新生成失败", { conversationId, error: msg, projectId });
});
}, [regenerate, conversationId, logger, projectId]);
const getCardExtra = useCallback(
(msg: UIMessage, idx: number) => {
const isLast = idx === messages.length - 1;
const lastUserIdx = messages.findLastIndex((m) => m.role === "user");
const isLastUser = lastUserIdx >= 0 && idx === lastUserIdx;
const isLastAssistant = isLast && msg.role === "assistant";
const isEditing = editingMessageId === msg.id;
if (isLoading) return null;
const buttons: React.ReactNode[] = [];
buttons.push(
<Button
className="btn-dimmed"
icon={<CopyOutlined />}
key="copy"
onClick={() => handleCopy(msg)}
size="small"
type="text"
/>,
);
if (isLastUser && !isEditing) {
buttons.push(
<Button
className="btn-dimmed"
icon={<EditOutlined />}
key="edit"
onClick={() => handleEditStart(msg)}
size="small"
type="text"
/>,
);
}
if (isLastAssistant) {
buttons.push(
<Button
className="btn-dimmed"
icon={<RedoOutlined />}
key="regenerate"
onClick={handleRegenerate}
size="small"
type="text"
/>,
);
}
return <Flex gap={4}>{buttons}</Flex>;
},
[messages, isLoading, editingMessageId, handleCopy, handleEditStart, handleRegenerate],
);
if (!conversationId) {
return (
<div className="app-chat-panel">
<div className="chat-welcome-area">
<Flex align="center" gap={12} vertical>
<RobotOutlined className="welcome-icon" />
<Typography.Title className="welcome-title" level={3}>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Flex>
</div>
<div className="chat-sender-area">
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={renderSenderFooter}
loading={isLoading}
onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
suffix={false}
value={input}
/>
</div>
</div>
);
}
return (
<div className="app-chat-panel">
{loadingHistory ? (
<div className="app-chat-panel-loading">
<Spin />
</div>
) : (
<ChatScrollArea messages={messages} status={status}>
<Flex gap={8} vertical>
{messages.map((msg, idx) => (
<Card
classNames={{ extra: "card-extra-actions" }}
extra={getCardExtra(msg, idx)}
key={msg.id}
size="small"
title={msg.role === "user" ? "用户" : <span className="msg-title-ai"></span>}
>
<div className="message-body">
{editingMessageId === msg.id ? (
<Flex gap={8} vertical>
<Input.TextArea
autoSize={{ maxRows: 6, minRows: 1 }}
onChange={(e) => setEditText(e.target.value)}
value={editText}
/>
<Flex gap={8} justify="flex-end">
<Button onClick={handleEditCancel} size="small">
</Button>
<Button onClick={handleEditConfirm} size="small" type="primary">
</Button>
</Flex>
</Flex>
) : (
msg.parts.map((part: Record<string, unknown>, i: number) => (
<PartRenderer
isStreaming={isLoading && idx === messages.length - 1 && msg.role === "assistant"}
key={i}
part={part}
role={msg.role}
/>
))
)}
</div>
</Card>
))}
{isLoading && (
<Flex className="chat-loading-indicator" justify="center">
<Spin size="small" />
</Flex>
)}
</Flex>
</ChatScrollArea>
)}
<div className="chat-sender-area">
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={renderSenderFooter}
loading={isLoading}
onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
suffix={false}
value={input}
/>
</div>
</div>
);
}
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 isStreaming={isStreaming} part={part} role={role} />;
}
if (partType.startsWith("tool-")) {
return <ToolPart part={part} />;
}
if (partType === "reasoning") {
return <ReasoningPart part={part} />;
}
return null;
}