2025-04-29 20:46:30 +08:00

292 lines
14 KiB
TypeScript

// File: frontend/app/chat/page.tsx (重构布局)
// Description: AI 聊天界面组件,包含左侧助手面板、中间聊天区、右侧会话面板
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { SendHorizontal, Loader2, PanelRightOpen, PanelRightClose, UserPlus, Settings2 } from 'lucide-react'; // 添加图标
import { sendChatMessage } from '@/lib/api';
// --- 数据接口定义 ---
interface Message {
id: string;
text: string;
sender: 'user' | 'ai';
isError?: boolean;
}
interface ChatSession {
id: string;
title: string;
createdAt: Date;
}
// 定义助手接口 (示例)
interface Assistant {
id: string;
name: string;
description: string;
avatar?: string; // 可选头像 URL 或 emoji
systemPrompt: string; // 核心:系统提示
model: string; // 例如 'gpt-3.5-turbo', 'gemini-pro'
temperature: number;
// 可以添加 top_p, max_tokens 等其他参数
}
export default function ChatPage() {
// --- State Variables ---
const [inputMessage, setInputMessage] = useState('');
const [messages, setMessages] = useState<Message[]>([
{ id: 'init-1', text: '你好!我是默认助手,有什么可以帮你的吗?', sender: 'ai' },
]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSessionPanelOpen, setIsSessionPanelOpen] = useState(true); // 右侧会话面板状态
// --- Mock Data ---
// 助手列表 Mock 数据
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'); // 当前选中的助手
// 会话列表 Mock 数据
const [sessions, setSessions] = useState<ChatSession[]>([
{id: 'session-1', title: '关于 RAG 技术的讨论', createdAt: new Date(Date.now() - 3600000)},
{id: 'session-2', title: 'Python FastAPI 项目构思', createdAt: new Date(Date.now() - 7200000)},
{id: 'session-3', title: '如何学习 LangChain', createdAt: new Date()},
]);
const [currentSessionId, setCurrentSessionId] = useState<string>('session-3'); // 当前选中的会话
// --- Refs ---
const messagesEndRef = useRef<null | HTMLDivElement>(null);
// --- Effects ---
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// --- Event Handlers ---
const handleSendMessage = async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const trimmedMessage = inputMessage.trim();
if (!trimmedMessage || isLoading) return;
setError(null);
const userMessage: Message = {
id: Date.now().toString(),
text: trimmedMessage,
sender: 'user',
};
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputMessage('');
setIsLoading(true);
try {
// TODO: 调用后端时需要传递 currentAssistantId 和 currentSessionId
// const aiReply = await sendChatMessage(trimmedMessage, currentSessionId, currentAssistantId);
const aiReply = await sendChatMessage(trimmedMessage); // 暂时保持不变
const aiMessage: Message = {
id: Date.now().toString() + '_ai',
text: aiReply,
sender: 'ai',
};
setMessages((prevMessages) => [...prevMessages, aiMessage]);
} catch (err: any) {
console.error("Failed to send message:", err);
const errorMessageText = err.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) => {
setCurrentAssistantId(assistantId);
// TODO: 可能需要清空当前消息或开始新会话
console.log("Selected assistant:", assistantId);
// 可以在这里加载助手的初始消息或更新聊天标题
const selectedAssistant = assistants.find(a => a.id === assistantId);
setMessages([
{ id: `init-${assistantId}`, text: `你好!我是${selectedAssistant?.name || '助手'}${selectedAssistant?.description || ''}`, sender: 'ai' },
]);
setCurrentSessionId(`new-${Date.now()}`); // 切换助手时通常开始新会话
setSessions(prev => [...prev, {id: `new-${Date.now()}`, title: `${selectedAssistant?.name} 的新对话`, createdAt: new Date()}]); // 添加新会话到列表
}
// --- 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 pr-1 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 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">
+
</button>
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
{sessions.map(session => (
<div
key={session.id}
onClick={() => setCurrentSessionId(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>
))}
</div>
</aside>
</div>
);
}