refactor(web): 前端目录重构 — consoles/pages → layouts/features + shared
- consoles/admin/ → layouts/admin-layout/ - consoles/workbench/ → layouts/workbench-layout/ + features/chat/ - pages/ → features/ (dashboard, models, projects, not-found) - components/ → shared/components/ - hooks/ → shared/hooks/ - utils/ → shared/utils/ - 更新所有 import 路径 (src/web/ + tests/web/) - 更新开发文档 (README.md, frontend.md, architecture.md)
This commit is contained in:
455
src/web/features/chat/ChatPanel.tsx
Normal file
455
src/web/features/chat/ChatPanel.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
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 { 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 { useModelList } from "../../shared/hooks/use-models";
|
||||
import { ChatScrollArea } from "./ChatScrollArea";
|
||||
import { ReasoningPart } from "./parts/ReasoningPart";
|
||||
import { TextPart } from "./parts/TextPart";
|
||||
import { ToolPart } from "./parts/ToolPart";
|
||||
|
||||
interface ChatPanelProps {
|
||||
conversationId: null | string;
|
||||
onConversationCreated: (id: string) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ChatPanel({ conversationId, onConversationCreated, projectId }: 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 { data: modelsData } = useModelList({ pageSize: 200 });
|
||||
const textModels = useMemo(
|
||||
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
|
||||
[modelsData],
|
||||
);
|
||||
|
||||
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 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={(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>
|
||||
);
|
||||
}
|
||||
|
||||
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={(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({
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user