292 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|