Files
vf_react/src/views/AgentChat/neuratalk/components/Chat/MCPChat.tsx
2025-11-22 09:57:30 +08:00

301 lines
9.5 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.

// components/Chat/MCPChat.tsx - 集成 MCP 的聊天组件
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { mcpService } from '../../services/mcp-real';
import { useAuth } from '../../hooks/useAuth';
import styles from './MCPChat.module.css';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
isStreaming?: boolean;
}
export default function MCPChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [streamingMessage, setStreamingMessage] = useState('');
const [tools, setTools] = useState<any[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { user, isAuthenticated, canAccessChat, loading: authLoading } = useAuth();
// 加载可用工具
useEffect(() => {
if (isAuthenticated && canAccessChat) {
loadTools();
}
}, [isAuthenticated, canAccessChat]);
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingMessage]);
const loadTools = async () => {
try {
const availableTools = await mcpService.getTools();
setTools(availableTools);
console.log('Available tools:', availableTools);
} catch (error) {
console.error('Failed to load tools:', error);
}
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
setStreamingMessage('');
try {
// 创建助手消息占位符
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
};
setMessages(prev => [...prev, assistantMessage]);
// 流式接收回复
let fullContent = '';
for await (const chunk of mcpService.streamMessage(input)) {
fullContent += chunk;
setStreamingMessage(fullContent);
}
// 更新最终消息
setMessages(prev =>
prev.map(msg =>
msg.id === assistantMessage.id
? { ...msg, content: fullContent, isStreaming: false }
: msg
)
);
setStreamingMessage('');
} catch (error) {
console.error('Send message error:', error);
setMessages(prev => [
...prev,
{
id: Date.now().toString(),
role: 'system',
content: '抱歉,发送消息时出错了。请稍后重试。',
timestamp: new Date(),
},
]);
} finally {
setIsLoading(false);
}
};
const handleToolCall = async (toolName: string) => {
// 示例:调用工具
try {
setIsLoading(true);
const result = await mcpService.callTool(toolName, {});
console.log('Tool result:', result);
// 显示工具结果
setMessages(prev => [
...prev,
{
id: Date.now().toString(),
role: 'system',
content: `工具 ${toolName} 执行结果:${JSON.stringify(result.result, null, 2)}`,
timestamp: new Date(),
},
]);
} catch (error) {
console.error('Tool call error:', error);
} finally {
setIsLoading(false);
}
};
// 权限检查
if (authLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-lg">...</div>
</div>
);
}
if (!isAuthenticated) {
const mainAppUrl = process.env.NEXT_PUBLIC_MAIN_APP_URL || 'https://valuefrontier.cn';
return (
<div className="flex flex-col items-center justify-center h-screen">
<h2 className="text-2xl mb-4"></h2>
<a
href={`${mainAppUrl}/auth/sign-in?redirect=/ai-chat`}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
>
</a>
</div>
);
}
if (!canAccessChat) {
const mainAppUrl = process.env.NEXT_PUBLIC_MAIN_APP_URL || 'https://valuefrontier.cn';
return (
<div className="flex flex-col items-center justify-center h-screen">
<h2 className="text-2xl mb-4">使 AI </h2>
<p className="text-gray-600 mb-4"></p>
<p className="text-sm text-gray-500 mb-6">
{user?.subscription_tier || '未知'}
</p>
<div className="space-y-2">
<a
href={`${mainAppUrl}/subscription`}
className="block bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700"
>
</a>
<button
onClick={() => window.location.reload()}
className="block bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
>
</button>
</div>
</div>
);
}
return (
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
{/* 侧边栏 - 工具列表 */}
<div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 p-4">
<h3 className="font-semibold mb-4"></h3>
<div className="space-y-2">
{tools.map(tool => (
<button
key={tool.name}
onClick={() => handleToolCall(tool.name)}
disabled={isLoading}
className="w-full text-left p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
>
<div className="font-medium">{tool.name}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{tool.description}
</div>
</button>
))}
</div>
{/* 用户信息 */}
<div className="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white">
{user?.username?.[0]?.toUpperCase()}
</div>
<div>
<div className="font-medium">{user?.username}</div>
<div className="text-xs text-gray-500">{user?.subscription_tier}</div>
</div>
</div>
</div>
</div>
{/* 主聊天区域 */}
<div className="flex-1 flex flex-col">
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-2xl px-4 py-2 rounded-lg ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: msg.role === 'system'
? 'bg-yellow-100 text-yellow-800'
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
}`}
>
{msg.isStreaming ? (
<div>
{streamingMessage || (
<span className="inline-block animate-pulse">...</span>
)}
</div>
) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
<div className="text-xs mt-1 opacity-70">
{msg.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
<div className="flex space-x-2">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleSend()}
disabled={isLoading}
placeholder="输入消息..."
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-white"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '发送中...' : '发送'}
</button>
</div>
{/* 快捷操作 */}
<div className="flex space-x-2 mt-2">
<button
onClick={() => setInput('/help')}
className="text-sm text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setInput('/tools')}
className="text-sm text-blue-600 hover:underline"
>
</button>
<button
onClick={() => mcpService.clearHistory()}
className="text-sm text-red-600 hover:underline"
>
</button>
</div>
</div>
</div>
</div>
);
}
// CSS 模块文件需要单独创建