1015 lines
38 KiB
TypeScript
1015 lines
38 KiB
TypeScript
// 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>
|
||
);
|
||
}
|