418 lines
19 KiB
TypeScript
418 lines
19 KiB
TypeScript
// 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>
|
||
);
|
||
}
|