2025-04-30 04:39:36 +08:00

1015 lines
38 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.

// File: frontend/app/chat/page.tsx (更新以使用 API)
// Description: 对接后端 API 实现助手和会话的加载与管理
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import {
SendHorizontal,
Loader2,
PanelRightOpen,
PanelRightClose,
UserPlus,
Settings2,
Trash2,
Edit,
RefreshCw,
} from "lucide-react"; // 添加刷新图标
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
// Shadcn UI Components
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Toaster, toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton"; // 导入骨架屏
// API 函数和类型
import {
sendChatMessage,
getAssistants,
createAssistant,
updateAssistant,
deleteAssistant,
getSessionsByAssistant,
deleteSession,
getMessagesBySession,
Session,
ChatApiResponse,
Message as ApiMessage,
} from "@/lib/api"; // 确保路径正确
import {
Assistant,
AssistantCreateData,
AssistantUpdateData,
} from "@/types/assistant";
// --- Frontend specific Message type (includes optional isError) ---
interface Message extends ApiMessage {
// Extend the type from API
isError?: boolean; // Optional flag for frontend error styling
}
interface ChatSession {
id: string;
title: string;
createdAt: Date;
assistantId: string;
isTemporary?: boolean;
}
// --- Zod Schema for Assistant Form Validation ---
const assistantFormSchema = z.object({
name: z
.string()
.min(1, { message: "助手名称不能为空" })
.max(50, { message: "名称过长" }),
description: z.string().max(200, { message: "描述过长" }).optional(),
avatar: z.string().max(5, { message: "头像/Emoji 过长" }).optional(), // 简单限制长度
system_prompt: z
.string()
.min(1, { message: "系统提示不能为空" })
.max(4000, { message: "系统提示过长" }),
model: z.string({ required_error: "请选择一个模型" }),
temperature: z.number().min(0).max(1),
});
type AssistantFormData = z.infer<typeof assistantFormSchema>;
// 可选的模型列表
const availableModels = [
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
{ value: "gpt-4", label: "GPT-4" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "deepseek-coder", label: "DeepSeek Coder" }, // 示例
// 添加更多模型...
];
// --- Helper Function ---
const findLastSession = (
sessions: ChatSession[],
assistantId: string
): ChatSession | undefined => {
return sessions
.filter((s) => s.assistantId === assistantId && !s.isTemporary)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
};
// --- Assistant Form Component ---
interface AssistantFormProps {
assistant?: Assistant | null; // 传入表示编辑,否则是创建
onSave: (data: AssistantFormData, id?: string) => void; // 保存回调
onClose: () => void; // 关闭 Dialog 的回调
}
function AssistantForm({ assistant, onSave, onClose }: AssistantFormProps) {
const form = useForm<AssistantFormData>({
resolver: zodResolver(assistantFormSchema),
defaultValues: {
name: assistant?.name || "",
description: assistant?.description || "",
avatar: assistant?.avatar || "",
system_prompt: assistant?.system_prompt || "",
model: assistant?.model || availableModels[0].value, // 默认第一个模型
temperature: assistant?.temperature ?? 0.7, // 默认 0.7
},
});
const [isSaving, setIsSaving] = useState(false); // 添加保存状态
async function onSubmit(data: AssistantFormData) {
setIsSaving(true);
try {
await onSave(data, assistant?.id); // 调用异步保存函数
onClose(); // 成功后关闭
} catch (error) {
// 错误已在 onSave 中处理并 toast
} finally {
setIsSaving(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="例如:代码助手" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel> ()</FormLabel>
<FormControl>
<Input placeholder="简单描述助手的功能" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Avatar */}
<FormField
control={form.control}
name="avatar"
render={({ field }) => (
<FormItem>
<FormLabel> ()</FormLabel>
<FormControl>
<Input placeholder="输入 Emoji 或 URL" {...field} />
</FormControl>
<FormDescription>使 Emoji</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* System Prompt */}
<FormField
control={form.control}
name="system_prompt"
render={({ field }) => (
<FormItem>
<FormLabel> (System Prompt)</FormLabel>
<FormControl>
<Textarea
placeholder="定义助手的角色和行为..."
className="resize-y min-h-[100px]" // 允许垂直调整大小
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Model Selection */}
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择一个 AI 模型" />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Temperature */}
<FormField
control={form.control}
name="temperature"
render={({ field }) => (
<FormItem>
<FormLabel>
(Temperature): {field.value.toFixed(1)}
</FormLabel>
<FormControl>
{/* Shadcn Slider expects an array for value */}
<Slider
min={0}
max={1}
step={0.1}
defaultValue={[field.value]} // Use defaultValue for initial render
onValueChange={(value) => field.onChange(value[0])} // Update form state on change
className="py-2" // Add padding for better interaction
/>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isSaving}>
</Button>
</DialogClose>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isSaving ? "保存中..." : "保存助手"}
</Button>
</DialogFooter>
</form>
</Form>
);
}
// --- Main Chat Page Component ---
export default function ChatPage() {
// --- State Variables ---
const [inputMessage, setInputMessage] = useState("");
// Messages state now holds Message type from API
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false); // AI 回复加载状态
const [error, setError] = useState<string | null>(null); // 通用错误显示 (可选)
const [isSessionPanelOpen, setIsSessionPanelOpen] = useState(true);
const [isAssistantDialogOpen, setIsAssistantDialogOpen] = useState(false); // 控制助手表单 Dialog 显隐
const [editingAssistant, setEditingAssistant] = useState<Assistant | null>(
null
); // 当前正在编辑的助手
// Data Loading States
const [assistantsLoading, setAssistantsLoading] = useState(true);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
// Data State
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [currentAssistantId, setCurrentAssistantId] = useState<string | null>(
null
); // 初始为 null
const [allSessions, setAllSessions] = useState<Session[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); // 初始为 null
// --- Refs ---
const messagesEndRef = useRef<null | HTMLDivElement>(null);
// --- Effects ---
// Initial data loading (Assistants)
// Initial Assistant loading
useEffect(() => {
const loadAssistants = async () => {
setAssistantsLoading(true);
try {
const fetchedAssistants = await getAssistants();
setAssistants(fetchedAssistants);
const defaultAssistant =
fetchedAssistants.find((a) => a.id === "asst-default") ||
fetchedAssistants[0];
if (defaultAssistant) {
setCurrentAssistantId(defaultAssistant.id);
} else {
console.warn("No default or initial assistant found.");
}
} catch (apiError: any) {
toast.error(`加载助手列表失败: ${apiError.message}`);
setError(`无法加载助手: ${apiError.message}`);
} finally {
setAssistantsLoading(false);
}
};
loadAssistants();
}, []);
// Load sessions when assistant changes (remains same, but calls handleSelectSession internally)
useEffect(() => {
if (!currentAssistantId) return;
const loadSessions = async () => {
setSessionsLoading(true);
setCurrentSessionId(null);
setMessages([]);
try {
const fetchedSessions = await getSessionsByAssistant(currentAssistantId);
// Filter out sessions that might belong to a deleted assistant still in cache
const validAssistants = new Set(assistants.map(a => a.id));
setAllSessions(prev => [
...prev.filter(s => s.assistant_id !== currentAssistantId && validAssistants.has(s.assistant_id)),
...fetchedSessions
]);
const lastSession = fetchedSessions.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (lastSession) {
setCurrentSessionId(lastSession.id); // Trigger message loading effect
} else {
setCurrentSessionId('temp-new-chat');
const currentAssistant = assistants.find(a => a.id === currentAssistantId);
setMessages([{ id: `init-temp-${currentAssistantId}`, session_id: 'temp-new-chat', sender: 'ai', text: `开始与 ${currentAssistant?.name || '助手'} 的新对话吧!`, order: 0, created_at: new Date().toISOString() }]);
}
} catch (apiError: any) { toast.error(`加载会话列表失败: ${apiError.message}`); }
finally { setSessionsLoading(false); }
};
loadSessions();
}, [currentAssistantId]); // 空依赖数组,只在挂载时运行一次
// Load sessions when assistant changes
useEffect(() => {
if (!currentSessionId || currentSessionId === "temp-new-chat") {
// If it's temp-new-chat, messages are already set or should be empty initially
if (currentSessionId === "temp-new-chat" && messages.length === 0) {
// Ensure initial message is set
const currentAssistant = assistants.find(
(a) => a.id === currentAssistantId
);
setMessages([
{
id: `init-temp-${currentAssistantId}`,
session_id: "temp-new-chat",
sender: "ai",
text: `开始与 ${currentAssistant?.name || "助手"} 的新对话吧!`,
order: 0,
created_at: new Date().toISOString(),
},
]);
}
return;
}
const loadMessages = async () => {
setMessagesLoading(true);
setError(null); // Clear previous errors
console.log(`加载会话 ${currentSessionId} 的消息...`);
try {
const fetchedMessages = await getMessagesBySession(currentSessionId);
setMessages(fetchedMessages);
console.log(`成功加载 ${fetchedMessages.length} 条消息`);
} catch (apiError: any) {
toast.error(`加载消息失败: ${apiError.message}`);
setError(`无法加载消息: ${apiError.message}`);
setMessages([]); // Clear messages on error
} finally {
setMessagesLoading(false);
}
};
loadMessages();
}, [currentSessionId]); // 依赖助手 ID 和助手列表 (以防助手信息更新)
// Auto scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Filter sessions for the current assistant (UI display)
const currentAssistantSessions = React.useMemo(() => {
// 直接从 allSessions 过滤,因为加载时已经更新了
return allSessions
.filter((s) => s.assistant_id === currentAssistantId)
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
); // 按时间倒序
}, [allSessions, currentAssistantId]);
// --- Assistant CRUD Handlers (Updated with API calls) ---
const handleSaveAssistant = async (data: AssistantFormData, id?: string) => {
try {
let savedAssistant: Assistant;
if (id) {
// 编辑
savedAssistant = await updateAssistant(id, data);
setAssistants((prev) =>
prev.map((a) => (a.id === id ? savedAssistant : a))
);
toast.success(`助手 "${savedAssistant.name}" 已更新`);
} else {
// 创建
savedAssistant = await createAssistant(data);
setAssistants((prev) => [...prev, savedAssistant]);
toast.success(`助手 "${savedAssistant.name}" 已创建`);
// 创建后自动选中
handleSelectAssistant(savedAssistant.id);
}
} catch (apiError: any) {
toast.error(`保存助手失败: ${apiError.message}`);
throw apiError; // 重新抛出错误,让表单的 finally 处理 isSaving
}
};
const handleDeleteAssistant = async (idToDelete: string) => {
if (idToDelete === "asst-default" || assistants.length <= 1) {
toast.error("不能删除默认助手或最后一个助手");
return;
}
if (idToDelete === currentAssistantId) {
toast.error("请先切换到其他助手再删除");
return;
}
const assistantToDelete = assistants.find((a) => a.id === idToDelete);
if (
window.confirm(
`确定要删除助手 "${assistantToDelete?.name}" 吗?相关会话也将被删除。`
)
) {
try {
await deleteAssistant(idToDelete);
// 后端应负责删除关联会话,前端只需更新助手列表
setAssistants((prev) => prev.filter((a) => a.id !== idToDelete));
// (可选) 如果需要立即清除前端的会话缓存
// setAllSessions(prev => prev.filter(s => s.assistant_id !== idToDelete));
toast.success(`助手 "${assistantToDelete?.name}" 已删除`);
} catch (apiError: any) {
toast.error(`删除助手失败: ${apiError.message}`);
}
}
};
const handleEditAssistant = (assistant: Assistant) => {
setEditingAssistant(assistant);
setIsAssistantDialogOpen(true);
};
const handleOpenCreateAssistantDialog = () => {
setEditingAssistant(null);
setIsAssistantDialogOpen(true);
};
// --- Send Message Handler (Updated - handles new session ID from response) ---
const handleSendMessage = async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const trimmedMessage = inputMessage.trim();
if (
!trimmedMessage ||
isLoading ||
!currentSessionId ||
!currentAssistantId
)
return;
setError(null);
setIsLoading(true); // Start loading (for AI reply)
const tempUserMessageId = `temp-user-${Date.now()}`; // Temporary ID for optimistic update
const userMessageOptimistic: Message = {
id: tempUserMessageId,
session_id:
currentSessionId === "temp-new-chat" ? "temp" : currentSessionId, // Use temp session id if needed
text: trimmedMessage,
sender: "user",
order: (messages[messages.length - 1]?.order || 0) + 1, // Estimate order
created_at: new Date().toISOString(),
};
// Optimistic UI update: Add user message immediately
setMessages((prev) => [...prev, userMessageOptimistic]);
setInputMessage("");
let targetSessionId = currentSessionId; // Will be updated if new session is created
try {
const response: ChatApiResponse = await sendChatMessage(
trimmedMessage,
currentSessionId, // Send 'temp-new-chat' or actual ID
currentAssistantId
);
// Process successful response
const aiMessage: Message = {
id: `ai-${Date.now()}`, // Use temporary or actual ID from backend if provided
session_id: response.session_id || targetSessionId, // Use new session ID if available
text: response.reply,
sender: "ai",
order: userMessageOptimistic.order + 1, // Estimate order
created_at: new Date().toISOString(),
};
// Update messages: Replace temp user message with potential real one (if backend returned it)
// and add AI message. For simplicity, we just add the AI message.
// A more robust solution would involve matching IDs.
setMessages((prev) => [
...prev.filter((m) => m.id !== tempUserMessageId),
userMessageOptimistic,
aiMessage,
]); // Keep optimistic user msg for now
// If a new session was created by the backend
if (
response.session_id &&
response.session_title &&
currentSessionId === "temp-new-chat"
) {
const newSession: Session = {
id: response.session_id,
title: response.session_title,
assistant_id: currentAssistantId,
created_at: new Date().toISOString(), // Or use time from backend if available
};
setAllSessions((prev) => [...prev, newSession]);
setCurrentSessionId(newSession.id); // Switch to the new session ID
// Update the session_id of the messages just added
setMessages((prev) =>
prev.map((m) =>
m.session_id === "temp" ? { ...m, session_id: newSession.id } : m
)
);
console.log(
`前端已更新新会话信息: ID=${newSession.id}, Title=${newSession.title}`
);
}
} catch (apiError: any) {
// Handle error: Remove optimistic user message and show error
setMessages((prev) => prev.filter((m) => m.id !== tempUserMessageId));
const errorMessageText = apiError.message || "发生未知错误";
toast.error(`发送消息失败: ${errorMessageText}`);
setError(`发送消息失败: ${errorMessageText}`);
// Optionally add an error message to the chat
const errorMessage: Message = {
/* ... */ id: `err-${Date.now()}`,
session_id: targetSessionId,
text: `错误: ${errorMessageText}`,
sender: "ai",
order: userMessageOptimistic.order + 1,
created_at: new Date().toISOString(),
};
setMessages((prevMessages) => [...prevMessages, errorMessage]);
} finally {
setIsLoading(false); // Stop AI reply loading
}
};
// --- Other Handlers (基本不变, 但需要检查 currentAssistantId/currentSessionId 是否存在) ---
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputMessage(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey && !isLoading) {
e.preventDefault();
handleSendMessage();
}
};
const toggleSessionPanel = () => {
setIsSessionPanelOpen(!isSessionPanelOpen);
};
const handleSelectAssistant = (assistantId: string) => {
console.log("选择助手id",assistantId)
if (assistantId !== currentAssistantId) {
setCurrentAssistantId(assistantId); // 触发 useEffect 加载会话
console.log("当前助手id",currentAssistantId)
}
};
// Updated handleSelectSession to just set the ID, useEffect handles loading
const handleSelectSession = (sessionId: string) => {
if (sessionId !== currentSessionId) {
setCurrentSessionId(sessionId); // Trigger useEffect to load messages
}
};
const handleNewTopic = () => {
if (currentSessionId !== "temp-new-chat" && currentAssistantId) {
// 确保有助手被选中
setCurrentSessionId("temp-new-chat");
console.log("手动创建临时新对话");
}
};
// --- JSX Rendering ---
return (
// 最外层 Flex 容器
<div className="flex h-full gap-1">
{" "}
{/* 使用 gap 添加间距 */}
<Toaster position="top-center" richColors />
{/* 左侧助手面板 */}
<aside className="w-64 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 flex flex-col">
<h2 className="w-full text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 flex items-center justify-between">
<span></span>
{/* 添加刷新按钮 */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
// 重新加载助手列表
const loadAssistants = async () => {
setAssistantsLoading(true);
try {
const fetchedAssistants = await getAssistants();
setAssistants(fetchedAssistants);
const defaultAssistant =
fetchedAssistants.find((a) => a.id === "asst-default") ||
fetchedAssistants[0];
if (defaultAssistant) {
setCurrentAssistantId(defaultAssistant.id);
} else {
console.warn("No default or initial assistant found.");
}
} catch (apiError: any) {
toast.error(`加载助手列表失败: ${apiError.message}`);
setError(`无法加载助手: ${apiError.message}`);
} finally {
setAssistantsLoading(false);
}
}; // 将加载逻辑提取出来
loadAssistants();
}}
disabled={assistantsLoading}
>
<RefreshCw
size={16}
className={assistantsLoading ? "animate-spin" : ""}
/>
</Button>
</h2>
<Dialog
open={isAssistantDialogOpen}
onOpenChange={setIsAssistantDialogOpen}
>
<DialogTrigger asChild>
<Button
variant="default" // 使用 shadcn Button
size="sm" // 调整大小
className="mb-4 w-full bg-red-500 hover:bg-red-600 text-white" // 样式调整
onClick={handleOpenCreateAssistantDialog} // 点击时重置编辑状态并打开
>
<UserPlus size={16} className="mr-2" />
</Button>
</DialogTrigger>
{/* Dialog 内容 */}
<DialogContent className="sm:max-w-[600px]">
{" "}
{/* 调整宽度 */}
<DialogHeader>
<DialogTitle>
{editingAssistant ? "编辑助手" : "创建新助手"}
</DialogTitle>
<DialogDescription>
{editingAssistant
? "修改助手的配置信息。"
: "定义一个新助手的名称、行为和参数。"}
</DialogDescription>
</DialogHeader>
{/* 渲染助手表单 */}
<AssistantForm
key={editingAssistant?.id || "create"} // 添加 key 确保编辑时表单重置
assistant={editingAssistant}
onSave={handleSaveAssistant}
onClose={() => setIsAssistantDialogOpen(false)} // 传递关闭回调
/>
</DialogContent>
</Dialog>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{" "}
{/* 添加右内边距防止滚动条遮挡 */}
{/* 渲染助手列表 */}
{assistantsLoading ? (
// 显示骨架屏
Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="p-3 rounded-lg flex items-center gap-3"
>
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))
) : assistants.length === 0 ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">
</p>
) : (
// 渲染助手列表
assistants.map((assistant) => (
<div
key={assistant.id}
onClick={() => handleSelectAssistant(assistant.id)}
className={`group p-2 rounded-lg cursor-pointer flex items-center gap-3 relative ${
currentAssistantId === assistant.id
? "bg-red-100 dark:bg-red-900/50 ring-1 ring-red-300 dark:ring-red-700"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
title={assistant.description || ""}
>
{/* ... 助手头像和名称 ... */}
<span className="text-lg flex-shrink-0 w-6 text-center">
{assistant.avatar || "👤"}
</span>
<div className="flex-1 overflow-hidden">
<p
className={`text-sm font-medium truncate ${
currentAssistantId === assistant.id
? "text-red-800 dark:text-red-200"
: "text-gray-800 dark:text-gray-200"
}`}
>
{assistant.name}
</p>
</div>
{/* 编辑和删除按钮 */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
onClick={(e) => {
e.stopPropagation();
handleEditAssistant(assistant);
}} // 阻止事件冒泡
title="编辑助手"
>
<Edit size={14} />
</Button>
{assistant.id !== "asst-default" && ( // 不显示默认助手的删除按钮
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
onClick={(e) => {
e.stopPropagation();
handleDeleteAssistant(assistant.id);
}}
title="删除助手"
>
<Trash2 size={14} />
</Button>
)}
</div>
</div>
))
)}
</div>
</aside>
{/* 中间主聊天区域 */}
<div className="flex flex-col flex-1 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
{/* 聊天窗口标题 - 显示当前助手和切换会话按钮 */}
<div className="flex justify-between items-center p-4 border-b dark:border-gray-700">
{currentAssistantId ? (
<div className="flex items-center gap-2">
<span className="text-xl">
{assistants.find((a) => a.id === currentAssistantId)?.avatar ||
"👤"}
</span>
<h1 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
{assistants.find((a) => a.id === currentAssistantId)?.name ||
"加载中..."}
<span className="text-sm font-normal text-gray-500 dark:text-gray-400 ml-2">
(
{currentSessionId === "temp-new-chat"
? "新话题"
: allSessions.find((s) => s.id === currentSessionId)
?.title || (sessionsLoading ? "加载中..." : "选择话题")}
)
</span>
</h1>
</div>
) : (
<Skeleton className="h-6 w-48" /> // 助手加载中显示骨架屏
)}
<button
onClick={toggleSessionPanel}
className="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
title={isSessionPanelOpen ? "关闭会话面板" : "打开会话面板"}
>
{isSessionPanelOpen ? (
<PanelRightClose size={20} />
) : (
<PanelRightOpen size={20} />
)}
</button>
</div>
{/* 消息显示区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 可以添加一个全局错误提示 */}
{error && (
<p className="text-center text-sm text-red-500 dark:text-red-400">
{error}
</p>
)}
{messagesLoading ? (
// Message loading skeleton
<div className="space-y-4">
<Skeleton className="h-10 w-3/5 rounded-lg" />
<Skeleton className="h-12 w-4/5 ml-auto rounded-lg" />
<Skeleton className="h-8 w-1/2 rounded-lg" />
</div>
) : messages.length === 0 && currentSessionId !== "temp-new-chat" ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8">
{currentAssistantId
? "选择或新建一个话题开始聊天。"
: "请先选择一个助手。"}
</p>
) : (
// Render actual messages
messages.map((message) => (
<div
key={message.id} // Use message ID from DB
className={`flex ${
message.sender === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-xs md:max-w-md lg:max-w-lg px-4 py-2 rounded-lg shadow ${
message.sender === "user"
? "bg-red-500 text-white"
: message.isError // Check if it's an error message added by frontend
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300"
: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
}`}
>
<p className="text-sm whitespace-pre-wrap">{message.text}</p>
</div>
</div>
))
)}
{isLoading && (
<div className="flex justify-center items-center p-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-500 dark:text-gray-400" />
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
AI ...
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 消息输入区域 */}
<div className="p-4 border-t dark:border-gray-700">
<form
onSubmit={handleSendMessage}
className="flex items-center space-x-2"
>
<input
type="text"
value={inputMessage}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={isLoading ? "AI 正在回复..." : "输入你的消息..."}
disabled={
isLoading ||
messagesLoading ||
sessionsLoading ||
!currentAssistantId ||
!currentSessionId
} // 添加禁用条件
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:focus:ring-red-600 disabled:opacity-70 transition-opacity"
aria-label="聊天输入框"
/>
<Button
type="submit"
disabled={
!inputMessage.trim() ||
isLoading ||
messagesLoading ||
sessionsLoading ||
!currentAssistantId ||
!currentSessionId
} // 添加禁用条件
className="p-2 rounded-lg bg-red-500 text-white hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center h-10 w-10"
aria-label={isLoading ? "正在发送" : "发送消息"}
>
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<SendHorizontal size={20} />
)}
</Button>
</form>
</div>
</div>
{/* 右侧会话管理面板 */}
<aside
className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-2 flex flex-col transition-all duration-300 ease-in-out ${
isSessionPanelOpen
? "w-64"
: "w-0 p-0 border-0 overflow-hidden opacity-0"
}`}
>
{" "}
{/* 调整关闭时的样式 */}
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 whitespace-nowrap items-center justify-center">
</h2>{" "}
{/* 改为话题 */}
<Button
onClick={handleNewTopic} // 绑定新建话题事件
className="mb-4 w-full px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm whitespace-nowrap flex items-center justify-center gap-2 disabled:opacity-50"
disabled={
currentSessionId === "temp-new-chat" ||
sessionsLoading ||
!currentAssistantId
} // 添加禁用条件
>
+
</Button>
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
{sessionsLoading ? (
// 会话加载骨架屏
Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-8 w-full my-1.5 rounded-lg" />
))
) : currentAssistantSessions.length === 0 &&
currentSessionId !== "temp-new-chat" ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">
</p>
) : (
<>
{/* 渲染会话列表 */}
{currentAssistantSessions.map((session) => (
<div
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={`p-2 rounded-lg cursor-pointer text-sm truncate whitespace-nowrap ${
currentSessionId === session.id
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-400 font-medium"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
title={session.title}
>
{session.title}
{/* TODO: 添加删除会话按钮 */}
</div>
))}
{/* 新话题占位符 */}
{currentSessionId === "temp-new-chat" && (
<div className="p-2 rounded-lg text-sm truncate whitespace-nowrap bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-400 font-medium">
...
</div>
)}
</>
)}
</div>
</aside>
</div>
);
}