2
0
Просмотр исходного кода

feat: MCP 服务器启用/禁用管理功能

- 添加每个 MCP 的启用/禁用开关
- 禁用的 MCP 显示为灰色,不参与聊天请求
- 顶部统计显示:已启用 X/3 | 已连接 Y/3 | 已登录 Z/3
- localStorage 持久化启用状态
- 默认所有 MCP 为启用状态

feat: 静态首页欢迎组件

- 移除自动调用 AI 的欢迎逻辑
- 使用静态 WELCOME_SPEC 组件
- 包含快速操作按钮

feat: 开发环境测试账号快速复制

- 登录表单下方显示测试账号
- 一键复制邮箱和密码按钮
- User MCP: reader-test@example.com / ReaderTest2026@
- Admin MCP: admin-test@example.com / AdminTest2026@

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude AI 14 часов назад
Родитель
Сommit
bab626b065

+ 38 - 4
frontend-v2/app/mcp/page.tsx

@@ -13,10 +13,10 @@
  */
 'use client';
 
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect } from 'react';
 import Link from 'next/link';
 import { McpServerCard } from '@/components/McpServerCard';
-import { MCP_SERVERS } from '@/lib/mcp-token-manager';
+import { MCP_SERVERS, mcpTokenManager } from '@/lib/mcp-token-manager';
 
 // 每个服务器的连接状态
 interface ServerConnectionStatus {
@@ -24,17 +24,35 @@ interface ServerConnectionStatus {
   loggedIn: boolean;
 }
 
+// 每个服务器的启用状态
+interface ServerEnabledStatus {
+  enabled: boolean;
+}
+
 export default function McpPage() {
   const [connectionStatuses, setConnectionStatuses] = useState<Record<string, ServerConnectionStatus>>({});
+  const [enabledStatuses, setEnabledStatuses] = useState<Record<string, boolean>>({});
 
   const totalCount = Object.keys(MCP_SERVERS).length;
 
+  // 计算已启用数量
+  const enabledCount = Object.values(enabledStatuses).filter(enabled => enabled).length;
+
   // 计算已连接数量:所有健康的 MCP 服务器
   const connectedCount = Object.values(connectionStatuses).filter(status => status.healthy).length;
 
   // 计算已登录数量:有 token 的 MCP 服务器
   const loggedInCount = Object.values(connectionStatuses).filter(status => status.loggedIn).length;
 
+  // 初始化启用状态
+  useEffect(() => {
+    const initialEnabledStates: Record<string, boolean> = {};
+    for (const mcpType of Object.keys(MCP_SERVERS)) {
+      initialEnabledStates[mcpType] = mcpTokenManager.isEnabled(mcpType);
+    }
+    setEnabledStatuses(initialEnabledStates);
+  }, []);
+
   // 处理子组件报告的连接状态变化
   // 使用 useCallback 避免每次渲染创建新函数引用,防止子组件 useEffect 无限循环
   const handleConnectionStatusChange = useCallback((mcpType: string, status: ServerConnectionStatus) => {
@@ -51,6 +69,20 @@ export default function McpPage() {
     });
   }, []); // 空依赖数组,函数引用永远不变
 
+  // 处理子组件报告的启用状态变化
+  const handleEnabledChange = useCallback((mcpType: string, enabled: boolean) => {
+    setEnabledStatuses(prev => {
+      const current = prev[mcpType];
+      if (current === enabled) {
+        return prev; // 状态未改变,返回原对象
+      }
+      return {
+        ...prev,
+        [mcpType]: enabled
+      };
+    });
+  }, []);
+
   return (
     <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
       {/* Header */}
@@ -87,9 +119,10 @@ export default function McpPage() {
           </h2>
           <p className="text-gray-600 dark:text-gray-400">
             管理和配置您的 MCP 服务器连接。
-            <span className="font-medium text-green-600 dark:text-green-400"> 已连接 {connectedCount}/{totalCount}</span>
+            <span className="font-medium text-purple-600 dark:text-purple-400"> 已启用 {enabledCount}/{totalCount}</span>
+            <span className="font-medium text-green-600 dark:text-green-400 ml-2"> | 已连接 {connectedCount}/{totalCount}</span>
             {loggedInCount > 0 && (
-              <span className="font-medium text-blue-600 dark:text-blue-400"> | 已登录 {loggedInCount}/{totalCount}</span>
+              <span className="font-medium text-blue-600 dark:text-blue-400 ml-2"> | 已登录 {loggedInCount}/{totalCount}</span>
             )}
           </p>
         </div>
@@ -102,6 +135,7 @@ export default function McpPage() {
               mcpType={mcpType}
               config={config}
               onConnectionStatusChange={handleConnectionStatusChange}
+              onEnabledChange={handleEnabledChange}
             />
           ))}
         </div>

+ 88 - 13
frontend-v2/app/page.tsx

@@ -10,13 +10,14 @@
  */
 'use client';
 
-import React, { useState, useCallback, useEffect, useRef } from 'react';
+import React, { useState, useCallback, useRef } from 'react';
 import { useChat } from '@/lib/hooks';
 import ChatInput from '@/components/ChatInput';
 import ChatMessage from '@/components/ChatMessage';
 import Header from '@/components/Header';
-import ToolCallPanel from '@/components/ToolCallPanel';
+import JsonRenderer from '@/components/JsonRenderer';
 import { ActionProvider } from '@/lib/action-context';
+import type { ComponentSpec } from '@/lib/json-render-catalog';
 
 // 消息类型
 interface Message {
@@ -24,13 +25,91 @@ interface Message {
   content: string;
 }
 
+// 静态欢迎组件 spec
+const WELCOME_SPEC: ComponentSpec = {
+  type: 'card',
+  title: '欢迎使用 AI MCP Web UI',
+  className: 'bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 border-blue-200 dark:border-blue-800',
+  children: [
+    {
+      type: 'text',
+      content: '这是一个通用的 AI 助手界面,可以连接多个 MCP 服务器,提供强大的工具调用能力。',
+      variant: 'body',
+      className: 'mb-4'
+    },
+    {
+      type: 'heading',
+      level: 'h3',
+      text: '可用的 MCP 服务器',
+      className: 'mb-2 mt-4 text-gray-800 dark:text-gray-200'
+    },
+    {
+      type: 'stack',
+      direction: 'column',
+      spacing: 1,
+      className: 'mb-4',
+      children: [
+        {
+          type: 'text',
+          content: '🌐 Novel Translator MCP - 文本翻译工具',
+          variant: 'body',
+          className: 'text-sm text-gray-600 dark:text-gray-400'
+        },
+        {
+          type: 'text',
+          content: '📚 Novel Platform User MCP - 小说阅读功能',
+          variant: 'body',
+          className: 'text-sm text-gray-600 dark:text-gray-400'
+        },
+        {
+          type: 'text',
+          content: '⚙️ Novel Platform Admin MCP - 平台管理功能',
+          variant: 'body',
+          className: 'text-sm text-gray-600 dark:text-gray-400'
+        }
+      ]
+    },
+    {
+      type: 'heading',
+      level: 'h3',
+      text: '快速开始',
+      className: 'mb-3 mt-6 text-gray-800 dark:text-gray-200'
+    },
+    {
+      type: 'suggestion-buttons',
+      suggestions: [
+        {
+          label: '翻译一段文本',
+          message: '请帮我把以下英文翻译成中文:Hello, how are you?',
+          icon: '🌐'
+        },
+        {
+          label: '查看小说列表',
+          message: '请帮我获取小说列表',
+          icon: '📚'
+        },
+        {
+          label: '获取平台统计',
+          message: '请帮我获取平台统计数据',
+          icon: '📊'
+        },
+        {
+          label: '查看 MCP 状态',
+          message: '请帮我检查所有 MCP 服务器的连接状态',
+          icon: '🔍'
+        }
+      ]
+    }
+  ]
+};
+
 export default function Home() {
   const [history, setHistory] = useState<Message[]>([]);
   const { sendMessage, isLoading, response, error, abort } = useChat();
   const pendingMessageRef = useRef<string | null>(null);
 
   // 当加载完成时,将完整的助手消息添加到历史记录
-  useEffect(() => {
+  React.useEffect(() => {
     console.log('[useEffect] Triggered:', {
       isLoading,
       pendingMessage: pendingMessageRef.current,
@@ -89,18 +168,14 @@ export default function Home() {
         <div className="flex-1 flex flex-col min-w-0">
           <div className="flex-1 overflow-y-auto p-3 md:p-4">
             {history.length === 0 ? (
-              <div className="flex items-center justify-center h-full text-gray-400">
-                <div className="text-center px-4">
-                  <h2 className="text-xl md:text-2xl font-semibold mb-2">欢迎使用 AI MCP Web UI</h2>
-                  <p className="text-sm md:text-base">开始与 AI 对话,支持 MCP 工具调用和生成式 UI</p>
-                  <div className="mt-4 text-xs md:text-sm space-y-1">
-                    <p>✨ 支持 json-render 动态组件渲染</p>
-                    <p>🔧 MCP 工具调用结果可视化</p>
-                    <p>🌐 翻译、小说阅读等专用组件</p>
-                    <p>👆 点击组件可自动发送消息</p>
+              // 空状态:显示静态欢迎组件
+              <ActionProvider actions={actionContext()}>
+                <div className="flex items-start justify-center h-full pt-4 md:pt-8">
+                  <div className="w-full max-w-2xl px-2">
+                    <JsonRenderer spec={WELCOME_SPEC} />
                   </div>
                 </div>
-              </div>
+              </ActionProvider>
             ) : (
               // 使用 ActionProvider 包装整个消息区域,使 ChatMessage 内部的 JsonRenderer 可以访问 ActionContext
               <ActionProvider actions={actionContext()}>

+ 15 - 4
frontend-v2/components/Header.tsx

@@ -13,6 +13,7 @@ import { mcpTokenManager } from '@/lib/mcp-token-manager';
 
 export default function Header() {
   const [loggedInCount, setLoggedInCount] = useState(0);
+  const [enabledCount, setEnabledCount] = useState(0);
   const [total, setTotal] = useState(0);
 
   useEffect(() => {
@@ -20,6 +21,7 @@ export default function Header() {
     const updateStatus = () => {
       const servers = Object.keys(mcpTokenManager as any).filter(k => k.startsWith('novel-'));
       setLoggedInCount(mcpTokenManager.getLoggedInMcpList().length);
+      setEnabledCount(mcpTokenManager.getEnabledMcpList().length);
       setTotal(servers.length || 3);
     };
 
@@ -51,14 +53,23 @@ export default function Header() {
         <div className="flex items-center space-x-4">
           {/* MCP 连接状态 */}
           <div className="flex items-center space-x-2 text-sm">
-            <span className="text-gray-600 dark:text-gray-400">MCP 连接:</span>
+            <span className="text-gray-600 dark:text-gray-400">MCP:</span>
             <span className={`px-2 py-1 rounded ${
-              loggedInCount > 0
-                ? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
+              enabledCount > 0
+                ? 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
                 : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
             }`}>
-              {loggedInCount}/{total}
+              已启用 {enabledCount}/{total}
             </span>
+            {loggedInCount > 0 && (
+              <span className={`px-2 py-1 rounded ${
+                loggedInCount > 0
+                  ? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
+                  : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
+              }`}>
+                已登录 {loggedInCount}/{total}
+              </span>
+            )}
           </div>
 
           {loggedInCount > 0 ? (

+ 8 - 2
frontend-v2/components/JsonRenderer.tsx

@@ -9,7 +9,7 @@
 'use client';
 
 import React, { useState, useMemo, useContext } from 'react';
-import { jsonRenderRegistry } from '@/lib/json-render-registry';
+import { jsonRenderRegistry, renderChildren } from '@/lib/json-render-registry';
 import type { ComponentSpec } from '@/lib/json-render-catalog';
 import { ActionContext } from '@/lib/action-context'; // 导入 ActionContext
 
@@ -182,10 +182,16 @@ export default function JsonRenderer({
 
         if (CustomComponent) {
           // 传递 emit 函数给组件(如果存在 ActionContext)
-          const props = {
+          const props: any = {
             ...componentSpec,
             ...(actionContext && { emit: actionContext.emit }),
           };
+
+          // 为 Card 和 Stack 组件添加 renderChildren 函数
+          if (componentType === 'card' || componentType === 'stack') {
+            props.renderChildren = renderChildren;
+          }
+
           return <CustomComponent key={index} {...props} />;
         }
 

+ 47 - 8
frontend-v2/components/McpServerCard.tsx

@@ -76,9 +76,10 @@ interface McpServerCardProps {
   mcpType: string;
   config: McpServerConfig;
   onConnectionStatusChange?: (mcpType: string, status: { healthy: boolean; loggedIn: boolean }) => void;
+  onEnabledChange?: (mcpType: string, enabled: boolean) => void;
 }
 
-export function McpServerCard({ mcpType, config, onConnectionStatusChange }: McpServerCardProps) {
+export function McpServerCard({ mcpType, config, onConnectionStatusChange, onEnabledChange }: McpServerCardProps) {
   const [showLoginForm, setShowLoginForm] = useState(false);
   const [showTokenForm, setShowTokenForm] = useState(false);
   const [email, setEmail] = useState('');
@@ -92,6 +93,8 @@ export function McpServerCard({ mcpType, config, onConnectionStatusChange }: Mcp
   const [isHealthy, setIsHealthy] = useState<boolean | null>(null); // null=未检查, true=健康, false=不健康
   const [isCheckingHealth, setIsCheckingHealth] = useState(false);
   const [latency, setLatency] = useState<number | null>(null);
+  // 新增:启用/禁用状态
+  const [isEnabled, setIsEnabled] = useState(() => mcpTokenManager.isEnabled(mcpType));
 
   // 监听 localStorage 变化和组件挂载
   useEffect(() => {
@@ -133,6 +136,12 @@ export function McpServerCard({ mcpType, config, onConnectionStatusChange }: Mcp
         console.log(`[McpServerCard ${mcpType}] Storage changed:`, e.key);
         setUpdateTrigger(prev => prev + 1);
       }
+      // 监听启用/禁用状态变化
+      if (e.key === `mcp_enabled_${mcpType}`) {
+        const newState = e.newValue === 'true';
+        setIsEnabled(newState);
+        onEnabledChange?.(mcpType, newState);
+      }
     };
 
     window.addEventListener('storage', handleStorageChange);
@@ -231,16 +240,38 @@ export function McpServerCard({ mcpType, config, onConnectionStatusChange }: Mcp
     setUpdateTrigger(prev => prev + 1);
   };
 
+  const handleToggleEnabled = () => {
+    const newState = mcpTokenManager.toggleEnabled(mcpType);
+    setIsEnabled(newState);
+    onEnabledChange?.(mcpType, newState);
+  };
+
   return (
-    <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border dark:border-gray-700">
+    <div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border dark:border-gray-700 transition-all ${!isEnabled ? 'opacity-50 grayscale' : ''}`}>
       <div className="flex items-start justify-between mb-4">
         <div className="flex-1">
-          <h3 className="text-lg font-semibold text-gray-800 dark:text-white">
-            {config.name}
-          </h3>
+          <div className="flex items-center gap-3">
+            <h3 className="text-lg font-semibold text-gray-800 dark:text-white">
+              {config.name}
+            </h3>
+            {/* 启用/禁用开关 */}
+            <button
+              type="button"
+              onClick={handleToggleEnabled}
+              className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-all ${
+                isEnabled
+                  ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800'
+                  : 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
+              }`}
+              title={isEnabled ? '点击禁用此 MCP 服务器' : '点击启用此 MCP 服务器'}
+            >
+              <span className={`w-1.5 h-1.5 rounded-full ${isEnabled ? 'bg-green-500' : 'bg-gray-400'}`} />
+              {isEnabled ? '启用' : '禁用'}
+            </button>
+          </div>
           <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
             {config.authType === 'none' ? '无需认证' : '需要登录'}
-            {latency !== null && isHealthy && (
+            {latency !== null && isHealthy && isEnabled && (
               <span className="ml-2 text-xs text-gray-400">
                 ({latency}ms)
               </span>
@@ -264,7 +295,15 @@ export function McpServerCard({ mcpType, config, onConnectionStatusChange }: Mcp
         </div>
       )}
 
-      {showLoginForm && !isLoggedIn && (
+      {!isEnabled && (
+        <div className="mb-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
+          <p className="text-sm text-gray-600 dark:text-gray-400">
+            此 MCP 服务器已禁用。点击右上角的"启用"按钮来启用它。
+          </p>
+        </div>
+      )}
+
+      {showLoginForm && !isLoggedIn && isEnabled && (
         <form onSubmit={handleLogin} className="mb-4 space-y-3">
           <div>
             <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -379,7 +418,7 @@ export function McpServerCard({ mcpType, config, onConnectionStatusChange }: Mcp
         <p className="text-xs text-gray-500 dark:text-gray-400">
           {config.url}
         </p>
-        {config.authType === 'jwt' && (
+        {config.authType === 'jwt' && isEnabled && (
           <div className="flex gap-2">
             {!isLoggedIn ? (
               !showLoginForm && !showTokenForm && (

+ 71 - 0
frontend-v2/lib/mcp-token-manager.ts

@@ -308,6 +308,77 @@ export class McpTokenManager {
       this.healthCache.clear();
     }
   }
+
+  // ==================== 启用/禁用状态管理 ====================
+
+  /**
+   * 设置 MCP 服务器启用状态
+   */
+  setEnabled(mcpType: string, enabled: boolean): void {
+    if (typeof window === 'undefined') return;
+    localStorage.setItem(`mcp_enabled_${mcpType}`, enabled.toString());
+    console.log(`[McpTokenManager] MCP ${mcpType} ${enabled ? 'enabled' : 'disabled'}`);
+  }
+
+  /**
+   * 获取 MCP 服务器启用状态
+   * 默认返回 true(启用)
+   */
+  isEnabled(mcpType: string): boolean {
+    if (typeof window === 'undefined') return true;
+    const value = localStorage.getItem(`mcp_enabled_${mcpType}`);
+    return value === null ? true : value === 'true';
+  }
+
+  /**
+   * 切换 MCP 服务器启用状态
+   */
+  toggleEnabled(mcpType: string): boolean {
+    const newState = !this.isEnabled(mcpType);
+    this.setEnabled(mcpType, newState);
+    return newState;
+  }
+
+  /**
+   * 获取所有已启用的 MCP 列表
+   */
+  getEnabledMcpList(): string[] {
+    return Object.keys(MCP_SERVERS).filter(mcpType => this.isEnabled(mcpType));
+  }
+
+  /**
+   * 获取所有已启用且已登录的 MCP Token
+   * 用于聊天请求中携带 token
+   */
+  getAllTokens(): Record<string, string> {
+    const tokens: Record<string, string> = {};
+    for (const mcpType of Object.keys(MCP_SERVERS)) {
+      // 只返回已启用且已登录的 token
+      if (this.isEnabled(mcpType)) {
+        const token = this.getToken(mcpType);
+        if (token) {
+          tokens[mcpType] = token;
+        }
+      }
+    }
+    return tokens;
+  }
+
+  /**
+   * 获取已启用且已登录的 MCP 数量
+   */
+  getEnabledLoggedInCount(): number {
+    return Object.keys(MCP_SERVERS).filter(mcpType =>
+      this.isEnabled(mcpType) && this.isLoggedIn(mcpType)
+    ).length;
+  }
+
+  /**
+   * 获取已启用的 MCP 数量
+   */
+  getEnabledCount(): number {
+    return Object.keys(MCP_SERVERS).filter(mcpType => this.isEnabled(mcpType)).length;
+  }
 }
 
 // 导出单例实例