|
|
@@ -0,0 +1,381 @@
|
|
|
+import React, { useState, useEffect, useRef } from 'react';
|
|
|
+import { useNavigate } from 'react-router-dom';
|
|
|
+import { useAuth } from '../hooks/AuthProvider';
|
|
|
+import { aiAgentClient } from '@/client/api';
|
|
|
+import type { InferResponseType, InferRequestType } from 'hono/client';
|
|
|
+import {
|
|
|
+ SparklesIcon,
|
|
|
+ ChatBubbleLeftEllipsisIcon,
|
|
|
+ PaperAirplaneIcon,
|
|
|
+ ArrowLeftIcon,
|
|
|
+ UserIcon,
|
|
|
+ RobotIcon
|
|
|
+} from '@heroicons/react/24/outline';
|
|
|
+
|
|
|
+// 类型定义
|
|
|
+type AIAgent = InferResponseType<typeof aiAgentClient.$get, 200>['data'][0];
|
|
|
+type ChatResponse = InferResponseType<typeof aiAgentClient.chat.$post, 200>;
|
|
|
+type ChatRequest = InferRequestType<typeof aiAgentClient.chat.$post>['json'];
|
|
|
+
|
|
|
+// 消息类型
|
|
|
+interface Message {
|
|
|
+ id: string;
|
|
|
+ role: 'user' | 'assistant';
|
|
|
+ content: string;
|
|
|
+ timestamp: Date;
|
|
|
+}
|
|
|
+
|
|
|
+// 中国水墨风格色彩方案
|
|
|
+const COLORS = {
|
|
|
+ ink: {
|
|
|
+ light: '#f5f3f0', // 宣纸背景色
|
|
|
+ medium: '#d4c4a8', // 淡墨
|
|
|
+ dark: '#8b7355', // 浓墨
|
|
|
+ deep: '#3a2f26', // 焦墨
|
|
|
+ },
|
|
|
+ accent: {
|
|
|
+ red: '#a85c5c', // 朱砂
|
|
|
+ blue: '#4a6b7c', // 花青
|
|
|
+ green: '#5c7c5c', // 石绿
|
|
|
+ },
|
|
|
+ text: {
|
|
|
+ primary: '#2f1f0f', // 墨色文字
|
|
|
+ secondary: '#5d4e3b', // 淡墨文字
|
|
|
+ light: '#8b7355', // 极淡文字
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 中国水墨风格字体类
|
|
|
+const FONT_STYLES = {
|
|
|
+ title: 'font-serif text-3xl font-bold tracking-wide',
|
|
|
+ sectionTitle: 'font-serif text-2xl font-semibold tracking-wide',
|
|
|
+ body: 'font-sans text-base leading-relaxed',
|
|
|
+ caption: 'font-sans text-sm',
|
|
|
+ small: 'font-sans text-xs',
|
|
|
+};
|
|
|
+
|
|
|
+const AIAgentsPage: React.FC = () => {
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const { user } = useAuth();
|
|
|
+ const [agents, setAgents] = useState<AIAgent[]>([]);
|
|
|
+ const [selectedAgent, setSelectedAgent] = useState<AIAgent | null>(null);
|
|
|
+ const [messages, setMessages] = useState<Message[]>([]);
|
|
|
+ const [inputMessage, setInputMessage] = useState('');
|
|
|
+ const [isLoading, setIsLoading] = useState(false);
|
|
|
+ const [isAgentsLoading, setIsAgentsLoading] = useState(true);
|
|
|
+ const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
+
|
|
|
+ // 获取智能体列表
|
|
|
+ useEffect(() => {
|
|
|
+ fetchAgents();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 自动滚动到底部
|
|
|
+ useEffect(() => {
|
|
|
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
|
+ }, [messages]);
|
|
|
+
|
|
|
+ const fetchAgents = async () => {
|
|
|
+ try {
|
|
|
+ setIsAgentsLoading(true);
|
|
|
+ const response = await aiAgentClient.$get();
|
|
|
+ const data = await response.json();
|
|
|
+ setAgents(data.data);
|
|
|
+
|
|
|
+ // 默认选择第一个智能体
|
|
|
+ if (data.data.length > 0) {
|
|
|
+ setSelectedAgent(data.data[0]);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取智能体失败:', error);
|
|
|
+ } finally {
|
|
|
+ setIsAgentsLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSendMessage = async () => {
|
|
|
+ if (!inputMessage.trim() || !selectedAgent) return;
|
|
|
+
|
|
|
+ const userMessage: Message = {
|
|
|
+ id: `msg-${Date.now()}`,
|
|
|
+ role: 'user',
|
|
|
+ content: inputMessage,
|
|
|
+ timestamp: new Date(),
|
|
|
+ };
|
|
|
+
|
|
|
+ setMessages(prev => [...prev, userMessage]);
|
|
|
+ setInputMessage('');
|
|
|
+ setIsLoading(true);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await aiAgentClient.chat.$post({
|
|
|
+ json: {
|
|
|
+ agentId: selectedAgent.id,
|
|
|
+ message: inputMessage,
|
|
|
+ } as ChatRequest,
|
|
|
+ });
|
|
|
+
|
|
|
+ const data: ChatResponse = await response.json();
|
|
|
+
|
|
|
+ const aiMessage: Message = {
|
|
|
+ id: data.messageId,
|
|
|
+ role: 'assistant',
|
|
|
+ content: data.message,
|
|
|
+ timestamp: new Date(),
|
|
|
+ };
|
|
|
+
|
|
|
+ setMessages(prev => [...prev, aiMessage]);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('发送消息失败:', error);
|
|
|
+ const errorMessage: Message = {
|
|
|
+ id: `error-${Date.now()}`,
|
|
|
+ role: 'assistant',
|
|
|
+ content: '抱歉,我暂时无法回答这个问题,请稍后再试。',
|
|
|
+ timestamp: new Date(),
|
|
|
+ };
|
|
|
+ setMessages(prev => [...prev, errorMessage]);
|
|
|
+ } finally {
|
|
|
+ setIsLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
|
+ if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
+ e.preventDefault();
|
|
|
+ handleSendMessage();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (isAgentsLoading) {
|
|
|
+ return (
|
|
|
+ <div className="min-h-screen" style={{ backgroundColor: COLORS.ink.light }}>
|
|
|
+ <header
|
|
|
+ className="shadow-sm sticky top-0 z-10 border-b border-opacity-20"
|
|
|
+ style={{
|
|
|
+ backgroundColor: COLORS.ink.light,
|
|
|
+ borderColor: COLORS.ink.medium
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="px-4 py-4">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <button
|
|
|
+ onClick={() => navigate('/')}
|
|
|
+ className="mr-4 p-2 rounded-full hover:bg-gray-100 transition-colors"
|
|
|
+ >
|
|
|
+ <ArrowLeftIcon className="w-5 h-5" style={{ color: COLORS.text.primary }} />
|
|
|
+ </button>
|
|
|
+ <h1 className={FONT_STYLES.title} style={{ color: COLORS.text.primary }}>
|
|
|
+ 智能助手
|
|
|
+ </h1>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div className="flex items-center justify-center h-64">
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-4"></div>
|
|
|
+ <p style={{ color: COLORS.text.secondary }}>加载中...</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!selectedAgent) {
|
|
|
+ return (
|
|
|
+ <div className="min-h-screen" style={{ backgroundColor: COLORS.ink.light }}>
|
|
|
+ <header
|
|
|
+ className="shadow-sm sticky top-0 z-10 border-b border-opacity-20"
|
|
|
+ style={{
|
|
|
+ backgroundColor: COLORS.ink.light,
|
|
|
+ borderColor: COLORS.ink.medium
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="px-4 py-4">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <button
|
|
|
+ onClick={() => navigate('/')}
|
|
|
+ className="mr-4 p-2 rounded-full hover:bg-gray-100 transition-colors"
|
|
|
+ >
|
|
|
+ <ArrowLeftIcon className="w-5 h-5" style={{ color: COLORS.text.primary }} />
|
|
|
+ </button>
|
|
|
+ <h1 className={FONT_STYLES.title} style={{ color: COLORS.text.primary }}>
|
|
|
+ 智能助手
|
|
|
+ </h1>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div className="flex items-center justify-center h-64">
|
|
|
+ <div className="text-center">
|
|
|
+ <RobotIcon className="w-16 h-16 mx-auto mb-4" style={{ color: COLORS.ink.dark }} />
|
|
|
+ <p style={{ color: COLORS.text.secondary }}>暂无可用智能体</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="min-h-screen flex flex-col" style={{ backgroundColor: COLORS.ink.light }}>
|
|
|
+ {/* 顶部导航 */}
|
|
|
+ <header
|
|
|
+ className="shadow-sm sticky top-0 z-10 border-b border-opacity-20"
|
|
|
+ style={{
|
|
|
+ backgroundColor: COLORS.ink.light,
|
|
|
+ borderColor: COLORS.ink.medium
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="px-4 py-4">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <button
|
|
|
+ onClick={() => navigate('/')}
|
|
|
+ className="mr-4 p-2 rounded-full hover:bg-gray-100 transition-colors"
|
|
|
+ >
|
|
|
+ <ArrowLeftIcon className="w-5 h-5" style={{ color: COLORS.text.primary }} />
|
|
|
+ </button>
|
|
|
+ <div>
|
|
|
+ <h1 className={FONT_STYLES.title} style={{ color: COLORS.text.primary }}>
|
|
|
+ {selectedAgent.name}
|
|
|
+ </h1>
|
|
|
+ <p className={FONT_STYLES.caption} style={{ color: COLORS.text.secondary }}>
|
|
|
+ {selectedAgent.description || '智能助手'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ {/* 智能体选择器 */}
|
|
|
+ {agents.length > 1 && (
|
|
|
+ <div className="px-4 py-3 border-b" style={{ borderColor: COLORS.ink.medium }}>
|
|
|
+ <div className="flex space-x-2 overflow-x-auto">
|
|
|
+ {agents.map(agent => (
|
|
|
+ <button
|
|
|
+ key={agent.id}
|
|
|
+ onClick={() => {
|
|
|
+ setSelectedAgent(agent);
|
|
|
+ setMessages([]);
|
|
|
+ }}
|
|
|
+ className={`px-4 py-2 rounded-full text-sm whitespace-nowrap transition-all ${
|
|
|
+ selectedAgent.id === agent.id
|
|
|
+ ? 'text-white shadow-md'
|
|
|
+ : 'border hover:shadow-sm'
|
|
|
+ }`}
|
|
|
+ style={{
|
|
|
+ backgroundColor: selectedAgent.id === agent.id ? COLORS.accent.blue : 'transparent',
|
|
|
+ borderColor: selectedAgent.id === agent.id ? COLORS.accent.blue : COLORS.ink.medium,
|
|
|
+ color: selectedAgent.id === agent.id ? 'white' : COLORS.text.primary,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {agent.name}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 消息区域 */}
|
|
|
+ <div className="flex-1 overflow-y-auto px-4 py-4">
|
|
|
+ {messages.length === 0 && (
|
|
|
+ <div className="text-center py-8">
|
|
|
+ <SparklesIcon className="w-16 h-16 mx-auto mb-4" style={{ color: COLORS.ink.dark }} />
|
|
|
+ <h3 className={`${FONT_STYLES.sectionTitle} mb-2`} style={{ color: COLORS.text.primary }}>
|
|
|
+ 你好,{user?.username || '朋友'}
|
|
|
+ </h3>
|
|
|
+ <p style={{ color: COLORS.text.secondary }}>
|
|
|
+ {selectedAgent.description || '有什么我可以帮助您的吗?'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="space-y-4">
|
|
|
+ {messages.map(message => (
|
|
|
+ <div
|
|
|
+ key={message.id}
|
|
|
+ className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
|
+ >
|
|
|
+ <div className={`max-w-[80%] ${message.role === 'user' ? 'order-1' : 'order-2'}`}>
|
|
|
+ <div
|
|
|
+ className="rounded-2xl px-4 py-3 shadow-sm"
|
|
|
+ style={{
|
|
|
+ backgroundColor: message.role === 'user' ? COLORS.accent.blue : 'white',
|
|
|
+ color: message.role === 'user' ? 'white' : COLORS.text.primary,
|
|
|
+ border: `1px solid ${message.role === 'user' ? COLORS.accent.blue : COLORS.ink.medium}`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <p className={FONT_STYLES.body}>{message.content}</p>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center mt-1 text-xs" style={{ color: COLORS.text.light }}>
|
|
|
+ {message.role === 'user' ? (
|
|
|
+ <UserIcon className="w-3 h-3 mr-1" />
|
|
|
+ ) : (
|
|
|
+ <RobotIcon className="w-3 h-3 mr-1" />
|
|
|
+ )}
|
|
|
+ <span>{message.timestamp.toLocaleTimeString()}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+
|
|
|
+ {isLoading && (
|
|
|
+ <div className="flex justify-start">
|
|
|
+ <div
|
|
|
+ className="rounded-2xl px-4 py-3 shadow-sm"
|
|
|
+ style={{
|
|
|
+ backgroundColor: 'white',
|
|
|
+ border: `1px solid ${COLORS.ink.medium}`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="flex space-x-2">
|
|
|
+ <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
|
|
+ <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
|
|
+ <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div ref={messagesEndRef} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 输入区域 */}
|
|
|
+ <div className="px-4 py-3 border-t" style={{ borderColor: COLORS.ink.medium }}>
|
|
|
+ <div className="flex items-center space-x-3">
|
|
|
+ <div className="flex-1">
|
|
|
+ <textarea
|
|
|
+ value={inputMessage}
|
|
|
+ onChange={(e) => setInputMessage(e.target.value)}
|
|
|
+ onKeyPress={handleKeyPress}
|
|
|
+ placeholder="输入您的问题..."
|
|
|
+ className="w-full px-4 py-3 rounded-2xl border resize-none"
|
|
|
+ style={{
|
|
|
+ backgroundColor: 'white',
|
|
|
+ borderColor: COLORS.ink.medium,
|
|
|
+ color: COLORS.text.primary,
|
|
|
+ fontSize: '16px',
|
|
|
+ minHeight: '48px',
|
|
|
+ maxHeight: '120px',
|
|
|
+ }}
|
|
|
+ rows={1}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={handleSendMessage}
|
|
|
+ disabled={!inputMessage.trim() || isLoading}
|
|
|
+ className="p-3 rounded-full transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ style={{
|
|
|
+ backgroundColor: COLORS.accent.blue,
|
|
|
+ color: 'white',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <PaperAirplaneIcon className="w-5 h-5" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default AIAgentsPage;
|