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:
2026-06-02 23:17:28 +08:00
parent 1f05f259d0
commit b1dec691e9
76 changed files with 249 additions and 111 deletions

View 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;
}