2025-04-30 00:52:29 +08:00

418 lines
19 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 (更新会话逻辑)
// Description: AI 聊天界面,实现助手关联会话、临时新对话和发送时创建会话
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { SendHorizontal, Loader2, PanelRightOpen, PanelRightClose, UserPlus, Settings2 } from 'lucide-react';
import { sendChatMessage } from '@/lib/api'; // 假设 sendChatMessage 只需 message
// --- 数据接口定义 ---
interface Message {
id: string;
text: string;
sender: 'user' | 'ai';
isError?: boolean;
}
interface ChatSession {
id: string; // 唯一 ID例如 'session-uuid-123' 或 'temp-new-chat'
title: string;
createdAt: Date;
assistantId: string; // 关联的助手 ID
isTemporary?: boolean; // 标记是否为临时会话
}
interface Assistant {
id: string;
name: string;
description: string;
avatar?: string;
systemPrompt: string;
model: string;
temperature: number;
}
// --- 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];
};
export default function ChatPage() {
// --- State Variables ---
const [inputMessage, setInputMessage] = useState('');
const [messages, setMessages] = useState<Message[]>([]); // 初始为空,由选中助手/会话决定
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSessionPanelOpen, setIsSessionPanelOpen] = useState(true);
// --- Mock Data & State ---
const [assistants, setAssistants] = useState<Assistant[]>([
{ id: 'asst-default', name: '默认助手', description: '通用聊天助手', avatar: '🤖', systemPrompt: '你是一个乐于助人的 AI 助手。', model: 'gpt-3.5-turbo', temperature: 0.7 },
{ id: 'asst-coder', name: '代码助手', description: '帮助编写和解释代码', avatar: '💻', systemPrompt: '你是一个专业的代码助手...', model: 'gpt-4', temperature: 0.5 },
{ id: 'asst-writer', name: '写作助手', description: '协助进行创意写作', avatar: '✍️', systemPrompt: '你是一位富有创意的写作伙伴...', model: 'gemini-pro', temperature: 0.9 },
]);
const [currentAssistantId, setCurrentAssistantId] = useState<string>('asst-default');
// 所有会话存储在一起,通过 assistantId 过滤
const [allSessions, setAllSessions] = useState<ChatSession[]>([
{id: 'session-1', title: '默认助手的讨论', createdAt: new Date(Date.now() - 3600000), assistantId: 'asst-default'},
{id: 'session-2', title: '代码调试记录', createdAt: new Date(Date.now() - 7200000), assistantId: 'asst-coder'},
{id: 'session-3', title: '默认助手的学习 LangChain', createdAt: new Date(), assistantId: 'asst-default'},
]);
// 当前激活的会话 ID可能是真实 ID 或 'temp-new-chat'
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
// --- Refs ---
const messagesEndRef = useRef<null | HTMLDivElement>(null);
// --- Effects ---
// 自动滚动
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// 根据当前助手 ID 过滤出会话列表
const currentAssistantSessions = React.useMemo(() => {
return allSessions.filter(s => s.assistantId === currentAssistantId);
}, [allSessions, currentAssistantId]);
// 当助手切换时,决定加载哪个会话
useEffect(() => {
const lastSession = findLastSession(allSessions, currentAssistantId);
if (lastSession) {
setCurrentSessionId(lastSession.id);
// TODO: 在实际应用中,这里需要调用 API 加载 lastSession.id 的历史消息
console.log(`加载助手 ${currentAssistantId} 的最后一个会话: ${lastSession.id}`);
// 模拟加载消息
const selectedAssistant = assistants.find(a => a.id === currentAssistantId);
setMessages([
{ id: `init-${lastSession.id}-1`, text: `继续与 ${selectedAssistant?.name || '助手'} 的对话: ${lastSession.title}`, sender: 'ai' },
// ... 应该加载真实历史消息
]);
} else {
// 没有历史会话,进入临时新对话状态
setCurrentSessionId('temp-new-chat');
console.log(`助手 ${currentAssistantId} 没有历史会话,创建临时新对话`);
const selectedAssistant = assistants.find(a => a.id === currentAssistantId);
setMessages([
{ id: `init-temp-${currentAssistantId}`, text: `开始与 ${selectedAssistant?.name || '助手'} 的新对话吧!`, sender: 'ai' },
]);
// 不将会话添加到 allSessions 列表
}
}, [currentAssistantId, allSessions, assistants]); // 依赖助手 ID 和所有会话
// --- Event Handlers ---
const handleSendMessage = async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const trimmedMessage = inputMessage.trim();
if (!trimmedMessage || isLoading) return;
setError(null);
setIsLoading(true); // 先设置加载状态
const userMessage: Message = {
id: Date.now().toString(),
text: trimmedMessage,
sender: 'user',
};
let targetSessionId = currentSessionId;
let newSessionCreated = false;
// 检查是否是临时新对话的第一条消息
if (currentSessionId === 'temp-new-chat') {
console.log("发送临时新对话的第一条消息,准备创建会话...");
try {
// --- 模拟后端创建会话并生成标题 ---
// TODO: 调用后端 API (e.g., POST /api/v1/sessions)
// const { newSessionId, newTitle } = await createSessionOnBackend(currentAssistantId, trimmedMessage);
await new Promise(resolve => setTimeout(resolve, 500)); // 模拟网络延迟
const newSessionId = `session-${Date.now()}`; // 模拟生成的新 ID
const newTitle = `关于 "${trimmedMessage.substring(0, 15)}..." 的讨论`; // 模拟 AI 生成的标题
// --- 模拟结束 ---
const newSession: ChatSession = {
id: newSessionId,
title: newTitle,
createdAt: new Date(),
assistantId: currentAssistantId,
isTemporary: false, // 不再是临时的
};
// 将新创建的会话添加到全局列表,并设为当前会话
setAllSessions(prev => [...prev, newSession]);
setCurrentSessionId(newSessionId);
targetSessionId = newSessionId; // 更新目标会话 ID
newSessionCreated = true; // 标记已创建
console.log(`新会话创建成功: ID=${newSessionId}, Title=${newTitle}`);
// 更新消息列表(先添加用户消息)
// 注意:因为 setMessages 是异步的,这里先更新,后续 AI 回复再更新
setMessages(prev => [...prev, userMessage]);
} catch (creationError: any) {
console.error("创建新会话失败:", creationError);
setError(`无法创建新会话: ${creationError.message}`);
setIsLoading(false);
return; // 创建失败则停止后续操作
}
} else {
// 不是新会话,直接将用户消息添加到当前消息列表
setMessages(prev => [...prev, userMessage]);
}
// 清空输入框
setInputMessage('');
// 发送消息到后端 (无论是否新创建会话,都需要发送)
try {
console.log(`发送消息到会话: ${targetSessionId}, 助手: ${currentAssistantId}`);
// TODO: 调用后端 API 时需要传递 targetSessionId 和 currentAssistantId
// const aiReply = await sendChatMessage(trimmedMessage, targetSessionId, currentAssistantId);
const aiReply = await sendChatMessage(trimmedMessage); // 暂时保持不变
const aiMessage: Message = {
id: Date.now().toString() + '_ai',
text: aiReply,
sender: 'ai',
};
// 使用函数式更新,确保基于最新的消息列表添加回复
setMessages((prevMessages) => [...prevMessages, aiMessage]);
} catch (sendError: any) {
console.error("发送消息失败:", sendError);
const errorMessageText = sendError.message || 'An unknown error occurred.';
setError(errorMessageText); // 更新错误状态
const errorMessage: Message = {
id: Date.now().toString() + '_err',
text: `错误: ${errorMessageText}`,
sender: 'ai',
isError: true,
};
setMessages((prevMessages) => [...prevMessages, errorMessage]);
} finally {
setIsLoading(false); // 无论成功失败,结束加载
}
};
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) => {
if (assistantId !== currentAssistantId) {
setCurrentAssistantId(assistantId);
// 后续逻辑由 useEffect 处理
}
}
// 点击会话列表项的处理函数
const handleSelectSession = (sessionId: string) => {
if (sessionId !== currentSessionId) {
setCurrentSessionId(sessionId);
// TODO: 调用 API 加载该会话的历史消息
console.log(`切换到会话: ${sessionId}`);
// 模拟加载消息
const session = allSessions.find(s => s.id === sessionId);
const assistant = assistants.find(a => a.id === session?.assistantId);
setMessages([
{ id: `init-${sessionId}-1`, text: `继续与 ${assistant?.name || '助手'} 的对话: ${session?.title || ''}`, sender: 'ai' },
// ... 加载真实历史消息
]);
}
}
// 点击新建话题按钮
const handleNewTopic = () => {
if (currentSessionId !== 'temp-new-chat') {
setCurrentSessionId('temp-new-chat');
const selectedAssistant = assistants.find(a => a.id === currentAssistantId);
setMessages([
{ id: `init-temp-${currentAssistantId}`, text: `开始与 ${selectedAssistant?.name || '助手'} 的新对话吧!`, sender: 'ai' },
]);
console.log("手动创建临时新对话");
}
}
// --- JSX Rendering ---
return (
// 最外层 Flex 容器
<div className="flex h-full gap-1"> {/* 使用 gap 添加间距 */}
{/* 左侧助手面板 */}
<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>
{/* TODO: 添加助手创建/编辑入口 */}
{/* <button className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" title="管理助手">
<Settings2 size={18} />
</button> */}
</h2>
<button className="mb-4 w-full px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm flex items-center justify-center gap-2">
<UserPlus size={16} />
</button>
<div className="flex-1 overflow-y-auto space-y-2 pr-1"> {/* 添加右内边距防止滚动条遮挡 */}
{/* 渲染助手列表 */}
{assistants.map(assistant => (
<div
key={assistant.id}
onClick={() => handleSelectAssistant(assistant.id)}
className={`p-3 rounded-lg cursor-pointer flex items-center gap-3 ${
currentAssistantId === assistant.id
? 'bg-red-100 dark:bg-red-900/50 ring-2 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-xl">{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>
<p className={`text-xs truncate ${currentAssistantId === assistant.id ? 'text-red-600 dark:text-red-400' : 'text-gray-500 dark:text-gray-400'}`}>
{assistant.description}
</p>
</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">
<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 || '助手'}
</h1>
{/* TODO: 显示模型、温度等信息 */}
{/* <span className="text-xs bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">
{assistants.find(a => a.id === currentAssistantId)?.model}
</span> */}
</div>
<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">
{messages.map((message) => (
<div
key={message.id}
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
? '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}
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}
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'} // 如果已经是新话题则禁用
>
+
</button>
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
{currentAssistantSessions
.filter(s => !s.isTemporary) // 不显示临时的
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) // 按时间倒序
.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}
</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>
);
}