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

feat: 简化翻译卡片 + MCP 管理功能

- 简化 TranslationResult 组件,只显示译文和术语
- 修复数据结构适配后端 MCP 响应
- 添加复制按钮反馈提示
- 新增 MCP 管理页面,支持多 MCP 登录
- 新增 McpTokenManager 单例管理 Token
- 更新 JsonRenderer 支持 action 处理
- 修复热更新配置

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

+ 99 - 0
frontend-v2/app/mcp/page.tsx

@@ -0,0 +1,99 @@
+/**
+ * MCP 管理页面
+ *
+ * 核心概念:
+ * - Web UI 是 MCP 测试工具,不需要全局登录
+ * - 每个 MCP 有独立的认证状态
+ * - 可以同时管理多个 MCP 的登录
+ */
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { McpServerCard } from '@/components/McpServerCard';
+import { MCP_SERVERS, mcpTokenManager } from '@/lib/mcp-token-manager';
+
+export default function McpPage() {
+  const [updateTrigger, setUpdateTrigger] = useState(0);
+  const [loggedInCount, setLoggedInCount] = useState(0); // SSR 安全的初始值
+
+  const totalCount = Object.keys(MCP_SERVERS).length;
+
+  // 页面加载时从 localStorage 读取登录状态
+  useEffect(() => {
+    setLoggedInCount(mcpTokenManager.getLoggedInMcpList().length);
+  }, [updateTrigger]);
+
+  // 强制刷新组件状态
+  const refresh = () => setUpdateTrigger(prev => prev + 1);
+
+  return (
+    <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
+      {/* Header */}
+      <header className="bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-6 py-4">
+        <div className="flex items-center justify-between">
+          <div className="flex items-center space-x-4">
+            <h1 className="text-xl font-bold text-gray-800 dark:text-white">
+              AI MCP Web UI
+            </h1>
+            <nav className="flex space-x-4">
+              <Link href="/" className="text-gray-600 dark:text-gray-300 hover:text-blue-500">
+                聊天
+              </Link>
+              <Link href="/mcp" className="text-blue-500 dark:text-blue-400 font-medium">
+                MCP 管理
+              </Link>
+            </nav>
+          </div>
+          <Link
+            href="/"
+            className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
+          >
+            开始聊天
+          </Link>
+        </div>
+      </header>
+
+      {/* Main Content */}
+      <main className="max-w-6xl mx-auto px-4 py-8">
+        {/* Page Header */}
+        <div className="mb-8">
+          <h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-2">
+            MCP 服务器管理
+          </h2>
+          <p className="text-gray-600 dark:text-gray-400">
+            管理和配置您的 MCP 服务器连接。已连接 {loggedInCount}/{totalCount} 个服务器。
+          </p>
+        </div>
+
+        {/* MCP Server Cards */}
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+          {Object.entries(MCP_SERVERS).map(([mcpType, config]) => (
+            <McpServerCard
+              key={mcpType}
+              mcpType={mcpType}
+              config={config}
+              onLoginSuccess={refresh}
+              onLogoutSuccess={refresh}
+            />
+          ))}
+        </div>
+
+        {/* Info Section */}
+        <div className="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg border dark:border-blue-800">
+          <h3 className="text-lg font-semibold text-blue-900 dark:text-blue-200 mb-3">
+            关于 MCP 认证
+          </h3>
+          <ul className="space-y-2 text-sm text-blue-800 dark:text-blue-300">
+            <li>• <strong>Novel Translator MCP</strong>: 无需登录,可直接使用</li>
+            <li>• <strong>Platform User MCP</strong>: 需要读者/作者账号登录</li>
+            <li>• <strong>Platform Admin MCP</strong>: 需要管理员账号登录</li>
+          </ul>
+          <p className="mt-3 text-sm text-blue-700 dark:text-blue-400">
+            登录后,对应的 MCP Token 会自动在聊天请求中携带,无需额外配置。
+          </p>
+        </div>
+      </main>
+    </div>
+  );
+}

+ 93 - 0
frontend-v2/components/ChatInput.tsx

@@ -0,0 +1,93 @@
+/**
+ * 聊天输入组件 - 移动端优化
+ */
+'use client';
+
+import { useState, FormEvent, KeyboardEvent, useRef, useEffect } from 'react';
+
+interface ChatInputProps {
+  onSend: (message: string) => void;
+  isLoading: boolean;
+  onAbort: () => void;
+}
+
+export default function ChatInput({ onSend, isLoading, onAbort }: ChatInputProps) {
+  const [input, setInput] = useState('');
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+  // 自动调整 textarea 高度
+  useEffect(() => {
+    if (textareaRef.current) {
+      textareaRef.current.style.height = 'auto';
+      textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
+    }
+  }, [input]);
+
+  const handleSubmit = (e: FormEvent) => {
+    e.preventDefault();
+    if (input.trim() && !isLoading) {
+      onSend(input.trim());
+      setInput('');
+      // 重置 textarea 高度
+      if (textareaRef.current) {
+        textareaRef.current.style.height = 'auto';
+      }
+    }
+  };
+
+  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key === 'Enter' && !e.shiftKey) {
+      e.preventDefault();
+      handleSubmit(e);
+    }
+  };
+
+  return (
+    <form onSubmit={handleSubmit} className="flex items-end gap-2">
+      <div className="flex-1 relative">
+        <textarea
+          ref={textareaRef}
+          value={input}
+          onChange={(e) => setInput(e.target.value)}
+          onKeyDown={handleKeyDown}
+          placeholder="输入消息... (Shift+Enter 换行)"
+          disabled={isLoading}
+          rows={1}
+          className="w-full resize-none border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-[16px] leading-relaxed transition-all"
+          style={{ minHeight: '48px', maxHeight: '150px' }}
+        />
+        {/* 字符计数 */}
+        {input.length > 0 && (
+          <span className="absolute bottom-2 right-3 text-xs text-gray-400">
+            {input.length}
+          </span>
+        )}
+      </div>
+
+      {/* 移动端:图标按钮 */}
+      {isLoading ? (
+        <button
+          type="button"
+          onClick={onAbort}
+          className="flex items-center justify-center w-12 h-12 min-w-[48px] bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all active:scale-95 shadow-sm"
+          aria-label="停止生成"
+        >
+          <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
+            <rect x="6" y="6" width="12" height="12" rx="2" />
+          </svg>
+        </button>
+      ) : (
+        <button
+          type="submit"
+          disabled={!input.trim()}
+          className="flex items-center justify-center w-12 h-12 min-w-[48px] bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-xl transition-all active:scale-95 shadow-sm disabled:active:scale-100"
+          aria-label="发送消息"
+        >
+          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
+          </svg>
+        </button>
+      )}
+    </form>
+  );
+}

+ 228 - 0
frontend-v2/components/JsonRenderer.tsx

@@ -0,0 +1,228 @@
+/**
+ * JsonRenderer 组件
+ *
+ * 基于 Vercel Labs json-render 的生成式 UI 渲染器
+ * 接收 AI 生成的 spec 并渲染为 React 组件
+ */
+
+'use client';
+
+import { useState, useMemo } from 'react';
+import { jsonRenderRegistry } from '@/lib/json-render-registry';
+import type { ComponentSpec } from '@/lib/json-render-catalog';
+
+// 避免直接导入 json-render 类型,使用 any 来处理类型兼容性
+type AnySpec = Record<string, any>;
+
+interface JsonRendererProps {
+  // 单个组件 spec
+  spec?: ComponentSpec | null;
+  // 多个组件 specs
+  specs?: ComponentSpec[];
+  // 原始 JSON 数据(会自动转换)
+  data?: unknown;
+  // MCP 工具调用数据
+  toolCalls?: Array<{ tool: string; result: unknown }>;
+  // 错误状态
+  error?: Error | null;
+  // 加载状态
+  isLoading?: boolean;
+  // 额外的 CSS 类名
+  className?: string;
+}
+
+export default function JsonRenderer({
+  spec,
+  specs,
+  data,
+  toolCalls,
+  error,
+  isLoading,
+  className = '',
+}: JsonRendererProps) {
+  // 处理多个 specs
+  const allSpecs = useMemo(() => {
+    const result: AnySpec[] = [];
+
+    // 添加单个 spec
+    if (spec) {
+      result.push(spec as AnySpec);
+    }
+
+    // 添加 specs 数组
+    if (specs && specs.length > 0) {
+      result.push(...(specs as AnySpec[]));
+    }
+
+    // 处理原始 data
+    if (data) {
+      // 尝试直接作为 spec
+      if (typeof data === 'object' && data !== null && 'type' in data) {
+        result.push(data as AnySpec);
+      }
+    }
+
+    // 处理 MCP 工具调用
+    if (toolCalls && toolCalls.length > 0) {
+      const { specFromToolCall } = require('@/lib/json-render-catalog');
+      for (const call of toolCalls) {
+        const toolSpec = specFromToolCall(call.tool, call.result);
+        if (toolSpec) {
+          result.push(toolSpec as AnySpec);
+        }
+      }
+    }
+
+    return result;
+  }, [spec, specs, data, toolCalls]);
+
+  // 加载状态
+  if (isLoading) {
+    return (
+      <div className={`json-renderer-loading ${className}`}>
+        <div className="flex items-center gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
+          <div className="animate-spin rounded-full h-5 w-5 border-2 border-blue-500 border-t-transparent"></div>
+          <span className="text-blue-700 dark:text-blue-300">Generating UI...</span>
+        </div>
+      </div>
+    );
+  }
+
+  // 错误状态
+  if (error) {
+    return (
+      <div className={`json-renderer-error ${className}`}>
+        <div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
+          <div className="flex items-center gap-2 text-red-700 dark:text-red-300">
+            <span>⚠️</span>
+            <span className="font-medium">Render Error</span>
+          </div>
+          <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error.message}</p>
+        </div>
+      </div>
+    );
+  }
+
+  // 空状态
+  if (allSpecs.length === 0) {
+    return null;
+  }
+
+  // 渲染组件
+  return (
+    <div className={`json-renderer space-y-4 ${className}`}>
+      {allSpecs.map((componentSpec, index) => {
+        const componentType = componentSpec.type as string;
+
+        // 使用自定义组件渲染
+        const CustomComponent = (jsonRenderRegistry as any)[componentType];
+
+        if (CustomComponent) {
+          return <CustomComponent key={index} {...componentSpec} />;
+        }
+
+        // Fallback
+        return (
+          <div key={index} className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
+            <p className="text-sm text-yellow-700 dark:text-yellow-300">
+              Unknown component type: {componentType}
+            </p>
+            <pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto">
+              {JSON.stringify(componentSpec, null, 2)}
+            </pre>
+          </div>
+        );
+      })}
+    </div>
+  );
+}
+
+/**
+ * 用于流式渲染的 Hook
+ * 当 spec 通过 SSE 流式传输时使用
+ */
+export function useStreamingJsonRenderer() {
+  const [specs, setSpecs] = useState<AnySpec[]>([]);
+  const [currentSpec, setCurrentSpec] = useState<Partial<AnySpec>>({});
+  const [isComplete, setIsComplete] = useState(false);
+
+  const addSpecChunk = (chunk: unknown) => {
+    if (typeof chunk === 'object' && chunk !== null) {
+      setCurrentSpec((prev) => ({ ...prev, ...chunk }));
+    }
+  };
+
+  const completeSpec = () => {
+    if (Object.keys(currentSpec).length > 0) {
+      setSpecs((prev) => [...prev, currentSpec]);
+      setCurrentSpec({});
+    }
+    setIsComplete(true);
+  };
+
+  const reset = () => {
+    setSpecs([]);
+    setCurrentSpec({});
+    setIsComplete(false);
+  };
+
+  return {
+    specs,
+    currentSpec,
+    isComplete,
+    addSpecChunk,
+    completeSpec,
+    reset,
+  };
+}
+
+/**
+ * 从 SSE 事件解析 spec
+ */
+export function parseSSEEvent(event: MessageEvent): AnySpec | null {
+  try {
+    const data = JSON.parse(event.data);
+
+    // 检查是否为组件 spec
+    if (data.type && typeof data.type === 'string') {
+      return data as AnySpec;
+    }
+
+    // 检查是否为工具调用结果
+    if (data.tool || data.tool_name) {
+      const { specFromToolCall } = require('@/lib/json-render-catalog');
+      const tool = data.tool || data.tool_name;
+      const result = data.result || data.data || data;
+      return specFromToolCall(tool as string, result) as AnySpec;
+    }
+
+    return null;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * 用于快速渲染单个工具调用结果的辅助组件
+ */
+interface ToolCallResultProps {
+  tool: string;
+  result: unknown;
+  className?: string;
+}
+
+export function ToolCallResult({ tool, result, className }: ToolCallResultProps) {
+  const { specFromToolCall } = require('@/lib/json-render-catalog');
+  const spec = specFromToolCall(tool, result);
+
+  if (!spec) {
+    return (
+      <div className={`p-4 bg-gray-100 dark:bg-gray-800 rounded ${className}`}>
+        <p className="text-sm text-gray-600 dark:text-gray-400">Tool: {tool}</p>
+        <pre className="mt-2 text-xs overflow-x-auto">{JSON.stringify(result, null, 2)}</pre>
+      </div>
+    );
+  }
+
+  return <JsonRenderer spec={spec as ComponentSpec} className={className} />;
+}

+ 200 - 0
frontend-v2/components/McpServerCard.tsx

@@ -0,0 +1,200 @@
+/**
+ * MCP 服务器卡片组件
+ */
+'use client';
+
+import { useState, useEffect } from 'react';
+import { MCP_SERVERS, mcpTokenManager, type McpServerConfig } from '@/lib/mcp-token-manager';
+
+interface McpServerCardProps {
+  mcpType: string;
+  config: McpServerConfig;
+  onLoginSuccess?: () => void;
+  onLogoutSuccess?: () => void;
+}
+
+export function McpServerCard({ mcpType, config, onLoginSuccess, onLogoutSuccess }: McpServerCardProps) {
+  const [showLoginForm, setShowLoginForm] = useState(false);
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState('');
+  const [updateTrigger, setUpdateTrigger] = useState(0);
+
+  // 监听 localStorage 变化和组件挂载
+  useEffect(() => {
+    // 初始化时检查登录状态
+    const checkLoginStatus = () => {
+      const token = localStorage.getItem(`mcp_token_${mcpType}`);
+      const username = localStorage.getItem(`mcp_username_${mcpType}`);
+      console.log(`[McpServerCard ${mcpType}] Initial check:`, {
+        hasToken: !!token,
+        tokenLength: token?.length,
+        username
+      });
+      setUpdateTrigger(prev => prev + 1);
+    };
+
+    checkLoginStatus();
+
+    // 监听 storage 事件(其他标签页的更改)
+    const handleStorageChange = (e: StorageEvent) => {
+      if (e.key?.startsWith('mcp_token_') || e.key?.startsWith('mcp_username_')) {
+        console.log(`[McpServerCard ${mcpType}] Storage changed:`, e.key);
+        setUpdateTrigger(prev => prev + 1);
+      }
+    };
+
+    window.addEventListener('storage', handleStorageChange);
+    return () => window.removeEventListener('storage', handleStorageChange);
+  }, [mcpType]);
+
+  const isLoggedIn = mcpTokenManager.isLoggedIn(mcpType);
+  const username = mcpTokenManager.getUsername(mcpType);
+  const remainingTime = mcpTokenManager.getTokenRemainingTime(mcpType);
+  const hoursRemaining = Math.floor(remainingTime / (1000 * 60 * 60));
+
+  const handleLogin = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setIsLoading(true);
+    setError('');
+
+    const result = await mcpTokenManager.loginMcp(mcpType, email, password);
+
+    if (result.success) {
+      setShowLoginForm(false);
+      setEmail('');
+      setPassword('');
+      // 强制刷新组件状态
+      setUpdateTrigger(prev => prev + 1);
+      onLoginSuccess?.();
+    } else {
+      setError(result.error || '登录失败');
+    }
+
+    setIsLoading(false);
+  };
+
+  const handleLogout = () => {
+    mcpTokenManager.logoutMcp(mcpType);
+    // 强制刷新组件状态
+    setUpdateTrigger(prev => prev + 1);
+    onLogoutSuccess?.();
+  };
+
+  return (
+    <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border dark:border-gray-700">
+      <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>
+          <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
+            {config.authType === 'none' ? '无需认证' : '需要登录'}
+          </p>
+        </div>
+        <div className={`flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
+          isLoggedIn
+            ? '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'
+        }`}>
+          <span className={`w-2 h-2 rounded-full ${isLoggedIn ? 'bg-green-500' : 'bg-gray-400'}`} />
+          {isLoggedIn ? '已连接' : '未连接'}
+        </div>
+      </div>
+
+      {isLoggedIn && (
+        <div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
+          <p className="text-sm text-green-800 dark:text-green-200">
+            已登录: <span className="font-medium">{username}</span>
+          </p>
+          <p className="text-xs text-green-600 dark:text-green-400 mt-1">
+            Token 有效期剩余约 {hoursRemaining} 小时
+          </p>
+        </div>
+      )}
+
+      {showLoginForm && !isLoggedIn && (
+        <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">
+              邮箱
+            </label>
+            <input
+              type="email"
+              value={email}
+              onChange={(e) => setEmail(e.target.value)}
+              required
+              className="w-full px-3 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
+              placeholder="your@email.com"
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+              密码
+            </label>
+            <input
+              type="password"
+              value={password}
+              onChange={(e) => setPassword(e.target.value)}
+              required
+              className="w-full px-3 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
+              placeholder="••••••••"
+            />
+          </div>
+          {error && (
+            <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
+          )}
+          <div className="flex gap-2">
+            <button
+              type="submit"
+              disabled={isLoading}
+              className="flex-1 py-2 px-4 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
+            >
+              {isLoading ? '登录中...' : '登录'}
+            </button>
+            <button
+              type="button"
+              onClick={() => {
+                setShowLoginForm(false);
+                setError('');
+                setEmail('');
+                setPassword('');
+              }}
+              className="py-2 px-4 border dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
+            >
+              取消
+            </button>
+          </div>
+        </form>
+      )}
+
+      <div className="flex items-center justify-between">
+        <p className="text-xs text-gray-500 dark:text-gray-400">
+          {config.url}
+        </p>
+        {config.authType === 'jwt' && (
+          <div>
+            {!isLoggedIn ? (
+              !showLoginForm && (
+                <button
+                  onClick={() => setShowLoginForm(true)}
+                  className="text-sm text-blue-500 hover:text-blue-600 font-medium"
+                >
+                  登录
+                </button>
+              )
+            ) : (
+              <button
+                onClick={handleLogout}
+                className="text-sm text-red-500 hover:text-red-600 font-medium"
+              >
+                登出
+              </button>
+            )}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 388 - 0
frontend-v2/lib/api-client.ts

@@ -0,0 +1,388 @@
+/**
+ * API 客户端 - 与 FastAPI 后端通信
+ * 使用 SuperJSON 处理序列化
+ */
+
+import { serialize, deserialize } from './superjson';
+import { mcpTokenManager } from './mcp-token-manager';
+
+// 配置 - 使用 Next.js rewrites 反向代理到 FastAPI
+const BACKEND_URL = '/api';
+
+// 类型定义
+export interface ChatMessage {
+  role: 'user' | 'assistant';
+  content: string;
+}
+
+export interface ChatRequest {
+  message: string;
+  history: ChatMessage[];
+}
+
+export interface ChatResponse {
+  response: string;
+  model: string;
+  tool_calls: Array<{
+    tool: string;
+    result: unknown;
+  }>;
+  has_tools: boolean;
+}
+
+export interface MCPServer {
+  id: string;
+  name: string;
+  url: string;
+  auth_type: 'none' | 'jwt';
+  enabled: boolean;
+}
+
+export interface MCPToolsResponse {
+  tools: Array<{
+    name: string;
+    description: string;
+    input_schema: Record<string, unknown>;
+  }>;
+  count: number;
+}
+
+export interface LoginRequest {
+  email: string;
+  password: string;
+}
+
+// 用户角色类型
+export type UserRole = 'reader' | 'author' | 'admin';
+
+// 用户信息接口
+export interface UserInfo {
+  id?: string;
+  username: string;
+  email?: string;
+  role: UserRole;
+}
+
+export interface LoginResponse {
+  success: boolean;
+  session_id: string;
+  username: string;
+  server: string;
+  role: UserRole;
+  token?: string;
+}
+
+export interface RegisterRequest {
+  email: string;
+  username: string;
+  password: string;
+}
+
+export interface AuthStatusResponse {
+  authenticated: boolean;
+  username?: string;
+  server?: string;
+  role?: string;
+}
+
+// API 客户端类
+export class ApiClient {
+  private baseUrl: string;
+  private sessionId: string | null = null;
+  private mcpTokens: Record<string, string> = {};
+  private userInfo: UserInfo | null = null;
+
+  constructor(baseUrl: string = BACKEND_URL) {
+    this.baseUrl = baseUrl;
+  }
+
+  // 设置会话(同时持久化到 localStorage)
+  setSession(sessionId: string) {
+    this.sessionId = sessionId;
+    // 持久化到 localStorage
+    if (typeof window !== 'undefined') {
+      localStorage.setItem('session_id', sessionId);
+    }
+  }
+
+  // 设置用户信息
+  setUserInfo(userInfo: UserInfo) {
+    this.userInfo = userInfo;
+    // 持久化到 localStorage
+    if (typeof window !== 'undefined') {
+      localStorage.setItem('userInfo', JSON.stringify(userInfo));
+    }
+  }
+
+  // 获取用户信息
+  getUserInfo(): UserInfo | null {
+    return this.userInfo;
+  }
+
+  // 根据角色获取 MCP URL
+  getMcpUrl(): string {
+    if (!this.userInfo) {
+      return '/mcp/'; // 默认使用 User MCP
+    }
+    // admin 使用 Admin MCP,其他角色使用 User MCP
+    return this.userInfo.role === 'admin' ? '/admin-mcp/' : '/mcp/';
+  }
+
+  // 设置 MCP Tokens
+  setMcpTokens(tokens: Record<string, string>) {
+    this.mcpTokens = { ...tokens };
+  }
+
+  // 获取请求头
+  private getHeaders(): HeadersInit {
+    const headers: HeadersInit = {
+      'Content-Type': 'application/json',
+    };
+
+    // 携带所有已登录的 MCP Token
+    const allMcpTokens = mcpTokenManager.getAllTokens();
+    if (Object.keys(allMcpTokens).length > 0) {
+      headers['X-MCP-Tokens'] = JSON.stringify(allMcpTokens);
+    }
+
+    return headers;
+  }
+
+  // 通用请求方法
+  private async request<T>(
+    endpoint: string,
+    options: RequestInit = {}
+  ): Promise<T> {
+    const url = `${this.baseUrl}${endpoint}`;
+    const response = await fetch(url, {
+      ...options,
+      headers: {
+        ...this.getHeaders(),
+        ...options.headers,
+      },
+    });
+
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({ error: response.statusText }));
+      throw new Error(error.error || error.detail || 'API request failed');
+    }
+
+    return response.json();
+  }
+
+  // 健康检查
+  async health(): Promise<{ status: string; model: string; mcp_servers: string[] }> {
+    return this.request('/health');
+  }
+
+  // 聊天(非流式)
+  async chat(message: string, history: ChatMessage[] = []): Promise<ChatResponse> {
+    return this.request<ChatResponse>('/chat', {
+      method: 'POST',
+      body: serialize({ message, history }),
+    });
+  }
+
+  // 聊天(流式)- 返回 EventSource
+  chatStream(message: string, history: ChatMessage[] = []): any {
+    const url = new URL(`${this.baseUrl}/chat/stream`);
+    const headers: Record<string, string> = {};
+
+    if (this.sessionId) {
+      headers['X-Session-ID'] = this.sessionId;
+    }
+
+    if (Object.keys(this.mcpTokens).length > 0) {
+      headers['X-MCP-Tokens'] = JSON.stringify(this.mcpTokens);
+    }
+
+    // 构建请求体
+    const body = { message, history };
+
+    // 使用 fetch 创建流式连接
+    const eventSource = new EventSourcePolyfill(url.toString(), {
+      headers,
+      method: 'POST',
+      body: JSON.stringify(body),
+    });
+
+    return eventSource;
+  }
+
+  // 使用 fetch 的流式请求
+  async chatStreamFetch(
+    message: string,
+    history: ChatMessage[] = [],
+    onEvent: (event: MessageEvent) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void
+  ): Promise<() => void> {
+    const url = `${this.baseUrl}/chat/stream`;
+    const controller = new AbortController();
+
+    try {
+      const response = await fetch(url, {
+        method: 'POST',
+        headers: this.getHeaders(),
+        body: JSON.stringify({ message, history }),
+        signal: controller.signal,
+      });
+
+      if (!response.ok) {
+        throw new Error(`HTTP error! status: ${response.status}`);
+      }
+
+      const reader = response.body?.getReader();
+      const decoder = new TextDecoder();
+
+      if (!reader) {
+        throw new Error('Response body is null');
+      }
+
+      let buffer = '';
+      let currentEvent = 'message';  // 默认事件类型
+
+      while (true) {
+        const { done, value } = await reader.read();
+
+        if (done) {
+          onComplete?.();
+          break;
+        }
+
+        buffer += decoder.decode(value, { stream: true });
+
+        // 处理 SSE 事件
+        const lines = buffer.split('\n');
+        buffer = lines.pop() || '';
+
+        for (const line of lines) {
+          if (line.startsWith('event: ')) {
+            // 提取事件类型
+            currentEvent = line.slice(7).trim();
+          } else if (line.startsWith('data: ')) {
+            // 提取数据并使用当前事件类型
+            const data = line.slice(6);
+            try {
+              onEvent(new MessageEvent(currentEvent, { data }));
+            } catch (e) {
+              console.error('Error parsing SSE data:', e);
+            }
+            // 重置事件类型为默认值
+            currentEvent = 'message';
+          }
+        }
+      }
+    } catch (error) {
+      if (error instanceof Error && error.name !== 'AbortError') {
+        onError?.(error);
+      }
+    }
+
+    return () => controller.abort();
+  }
+
+  // 获取 MCP 服务器列表
+  async listMcpServers(): Promise<{ servers: MCPServer[] }> {
+    return this.request('/mcp/servers');
+  }
+
+  // 获取 MCP 工具列表
+  async listMcpTools(): Promise<MCPToolsResponse> {
+    return this.request('/mcp/tools');
+  }
+
+  // 用户登录
+  async login(email: string, password: string): Promise<LoginResponse> {
+    const response = await this.request<LoginResponse>('/auth/login', {
+      method: 'POST',
+      body: JSON.stringify({ email, password }),
+    });
+    if (response.session_id) {
+      this.setSession(response.session_id);
+      // 保存用户信息
+      this.setUserInfo({
+        username: response.username,
+        role: response.role || 'reader',
+      });
+    }
+    return response;
+  }
+
+  // 管理员登录
+  async adminLogin(email: string, password: string): Promise<LoginResponse> {
+    const response = await this.request<LoginResponse>('/auth/admin-login', {
+      method: 'POST',
+      body: JSON.stringify({ email, password }),
+    });
+    if (response.session_id) {
+      this.setSession(response.session_id);
+      // 保存用户信息
+      this.setUserInfo({
+        username: response.username,
+        role: response.role || 'admin',
+      });
+    }
+    return response;
+  }
+
+  // 用户注册
+  async register(email: string, username: string, password: string): Promise<{ success: boolean; message: string; user: unknown }> {
+    return this.request('/auth/register', {
+      method: 'POST',
+      body: JSON.stringify({ email, username, password }),
+    });
+  }
+
+  // 登出(同时清除 localStorage)
+  async logout(): Promise<{ success: boolean }> {
+    const response = await this.request<{ success: boolean }>('/auth/logout', {
+      method: 'POST',
+      body: JSON.stringify({ session_id: this.sessionId }),
+    });
+    this.sessionId = null;
+    this.mcpTokens = {};
+    this.userInfo = null;
+    // 清除 localStorage
+    if (typeof window !== 'undefined') {
+      localStorage.removeItem('session_id');
+      localStorage.removeItem('username');
+      localStorage.removeItem('userInfo');
+    }
+    return response;
+  }
+
+  // 检查认证状态
+  async authStatus(): Promise<AuthStatusResponse> {
+    return this.request('/auth/status');
+  }
+}
+
+// EventSource polyfill for POST requests
+class EventSourcePolyfill {
+  private url: string;
+  private headers: Record<string, string>;
+  private onmessage: ((event: MessageEvent) => void) | null = null;
+  private onerror: ((event: Event) => void) | null = null;
+  private eventSource: EventSource | null = null;
+
+  constructor(url: string, options: { headers: Record<string, string>; method?: string; body?: string }) {
+    this.url = url;
+    this.headers = options.headers;
+    // 简化实现 - 实际应用中需要更完整的 polyfill
+    // 这里仅作为类型占位
+  }
+
+  addEventListener(type: string, listener: (event: MessageEvent) => void) {
+    if (type === 'message') {
+      this.onmessage = listener;
+    }
+  }
+
+  close() {
+    this.eventSource?.close();
+  }
+}
+
+// 单例实例
+export const apiClient = new ApiClient();

+ 379 - 0
frontend-v2/lib/hooks.ts

@@ -0,0 +1,379 @@
+/**
+ * 自定义 React Hooks - 用于与 FastAPI 后端交互
+ */
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { apiClient, type ChatMessage, type UserInfo, type UserRole } from './api-client';
+
+// SSE 事件类型
+export type SSEEventType =
+  | 'start'
+  | 'token'
+  | 'tools'
+  | 'tools_start'
+  | 'tool_call'
+  | 'tool_done'
+  | 'tool_error'
+  | 'complete'
+  | 'error';
+
+export interface SSEEvent {
+  type: SSEEventType;
+  data: unknown;
+}
+
+// 聊天 Hook
+export function useChat() {
+  const [isLoading, setIsLoading] = useState(false);
+  const [response, setResponse] = useState('');
+  const [toolCalls, setToolCalls] = useState<Array<{ tool: string; result: unknown }>>([]);
+  const [specs, setSpecs] = useState<any[]>([]);
+  const [error, setError] = useState<string | null>(null);
+  const abortRef = useRef<(() => void) | null>(null);
+  const toolCallsRef = useRef<Array<{ tool: string; result: unknown }>>([]);
+
+  // 保持 ref 同步
+  useEffect(() => {
+    toolCallsRef.current = toolCalls;
+    console.log('[useEffect] toolCalls updated:', toolCalls);
+  }, [toolCalls]);
+
+  // 监控 response 状态变化
+  useEffect(() => {
+    console.log('[useEffect] response updated:', {
+      length: response.length,
+      preview: response.substring(0, 100),
+    });
+  }, [response]);
+
+  // 生成 specs 的通用函数
+  const generateSpecs = useCallback(() => {
+    const currentToolCalls = toolCallsRef.current;
+    console.log('[generateSpecs] Current toolCalls:', currentToolCalls);
+    if (currentToolCalls.length > 0) {
+      const { specFromToolCall } = require('@/lib/json-render-catalog');
+      const newSpecs = currentToolCalls.map((call) => specFromToolCall(call.tool, call.result)).filter(Boolean);
+      console.log('[generateSpecs] Generated specs:', newSpecs);
+      setSpecs(newSpecs);
+    }
+    // 使用 setTimeout 确保 specs 状态更新先被处理
+    setTimeout(() => {
+      setIsLoading(false);
+      console.log('[generateSpecs] Set isLoading to false');
+    }, 0);
+  }, []);
+
+  const sendMessage = useCallback(async (message: string, history: ChatMessage[] = []) => {
+    setIsLoading(true);
+    setResponse('');
+    setToolCalls([]);
+    setSpecs([]);
+    setError(null);
+
+    try {
+      abortRef.current = await apiClient.chatStreamFetch(
+        message,
+        history,
+        (event) => {
+          try {
+            console.log('[SSE Raw] type:', event.type, 'data:', event.data?.substring(0, 100));
+            const data = JSON.parse(event.data);
+            // 将 SSE 事件类型添加到数据中,以便 handleSSEEvent 可以访问
+            (data as any).type = event.type;
+            handleSSEEvent(data);
+          } catch (e) {
+            console.error('Failed to parse SSE data:', e);
+          }
+        },
+        (err) => {
+          console.error('[sendMessage] Error:', err);
+          setError(err.message);
+          // 即使出错也生成 specs
+          generateSpecs();
+        },
+        () => {
+          // 对话完成时,生成最终的 specs
+          generateSpecs();
+        }
+      );
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Unknown error');
+      setIsLoading(false);
+    }
+  }, [generateSpecs]);
+
+  const handleSSEEvent = (data: unknown) => {
+    const event = data as { type?: string; [key: string]: unknown };
+
+    // 调试日志
+    console.log('[SSE Event]', event.type, JSON.stringify(event).substring(0, 200));
+
+    switch (event.type) {
+      case 'token':
+        const tokenData = event as { text?: string };
+        if (tokenData.text) {
+          setResponse((prev) => {
+            const newResponse = prev + tokenData.text;
+            console.log('[Token] Adding text:', {
+              text: tokenData.text,
+              prevLength: prev.length,
+              newLength: newResponse.length,
+              newPreview: newResponse.substring(0, 100),
+            });
+            return newResponse;
+          });
+        }
+        break;
+      case 'tool_call':
+        const toolCallData = event as { tool?: string; args?: unknown; tool_id?: string };
+        if (toolCallData.tool) {
+          const newToolCall = {
+            tool: toolCallData.tool!,
+            tool_id: toolCallData.tool_id,
+            args: toolCallData.args,  // 保存原始参数
+            result: toolCallData.args, // 初始 result 设为 args
+          };
+          setToolCalls((prev) => [...prev, newToolCall]);
+          console.log('[Tool Call] Added:', newToolCall);
+        }
+        break;
+      case 'tool_done':
+        const toolDoneData = event as { tool?: string; tool_id?: string; result?: unknown };
+        console.log('[Tool Done] Raw event:', toolDoneData);
+        console.log('[Tool Done] Result type:', typeof toolDoneData.result, 'Result:', toolDoneData.result);
+        // 优先使用 tool_id 匹配,回退到 tool 名称匹配
+        if (toolDoneData.tool_id || toolDoneData.tool) {
+          setToolCalls((prev) => {
+            console.log('[Tool Done] Current toolCalls before update:', prev);
+            console.log('[Tool Done] Looking for tool_id:', toolDoneData.tool_id, 'type:', typeof toolDoneData.tool_id);
+            const updated = [...prev];
+            // 使用 tool_id 查找
+            let index = updated.findIndex((t) => {
+              console.log('[Tool Done] Comparing:', t.tool_id, '===', toolDoneData.tool_id, 'result:', t.tool_id === toolDoneData.tool_id);
+              return t.tool_id === toolDoneData.tool_id;
+            });
+            console.log('[Tool Done] Found index:', index);
+            // 回退到 tool 名称查找
+            if (index < 0 && toolDoneData.tool) {
+              index = updated.findIndex((t) => t.tool === toolDoneData.tool);
+              console.log('[Tool Done] Found by tool name index:', index);
+            }
+            if (index >= 0) {
+              // 保留 args,只更新 result
+              const existing = updated[index];
+              updated[index] = {
+                ...existing,
+                result: toolDoneData.result,
+                // 确保 args 不被覆盖
+                args: existing.args,
+              };
+              console.log('[Tool Done] Updated tool call:', updated[index]);
+            } else {
+              console.log('[Tool Done] No matching tool_call found for:', toolDoneData);
+            }
+            return updated;
+          });
+        }
+        break;
+      case 'complete':
+        const completeData = event as { response?: string; tool_calls?: unknown };
+        console.log('[Complete]', completeData);
+        console.log('[Complete] Response field:', completeData.response?.substring(0, 100));
+        // 使用函数式更新来避免闭包陷阱
+        setResponse((prev) => {
+          // 如果 complete 事件有响应,优先使用它(因为它包含完整响应)
+          // 否则保留从 token 事件构建的响应
+          if (completeData.response) {
+            console.log('[Complete] Using complete response, replacing prev with length:', prev.length);
+            return completeData.response;
+          }
+          console.log('[Complete] No response in complete event, keeping prev with length:', prev.length);
+          return prev;
+        });
+        // 不在这里设置 isLoading(false),让 onComplete 回调处理
+        // 这样可以确保 specs 在 isLoading 变为 false 之前生成
+        break;
+      case 'error':
+        const errorData = event as { error?: string };
+        console.error('[SSE Error]', errorData);
+        setError(errorData.error || 'Unknown error');
+        // 不在这里设置 isLoading(false),让 stream 的 onError/onComplete 处理
+        // 这样可以确保 specs 在 isLoading 变为 false 之前生成
+        break;
+    }
+  };
+
+  const abort = useCallback(() => {
+    abortRef.current?.();
+    setIsLoading(false);
+  }, []);
+
+  return {
+    sendMessage,
+    abort,
+    isLoading,
+    response,
+    toolCalls,
+    specs,
+    error,
+  };
+}
+
+// 认证 Hook
+export function useAuth() {
+  const [isAuthenticated, setIsAuthenticated] = useState(false);
+  const [username, setUsername] = useState<string | null>(null);
+  const [role, setRole] = useState<UserRole | null>(null);
+  const [isLoading, setIsLoading] = useState(false);
+
+  // 从 localStorage 恢复认证状态
+  useEffect(() => {
+    const storedSessionId = localStorage.getItem('session_id');
+    const storedUserInfo = localStorage.getItem('userInfo');
+    if (storedSessionId) {
+      apiClient.setSession(storedSessionId);
+      setIsAuthenticated(true);
+    }
+    if (storedUserInfo) {
+      try {
+        const userInfo: UserInfo = JSON.parse(storedUserInfo);
+        setUsername(userInfo.username);
+        setRole(userInfo.role);
+        apiClient.setUserInfo(userInfo);
+      } catch (e) {
+        console.error('Failed to parse stored userInfo:', e);
+      }
+    }
+  }, []);
+
+  // 根据角色获取 MCP URL
+  const getMcpUrl = useCallback((): string => {
+    return apiClient.getMcpUrl();
+  }, []);
+
+  const login = useCallback(async (email: string, password: string) => {
+    setIsLoading(true);
+    try {
+      console.log('[useAuth] Starting login:', email);
+      const response = await apiClient.login(email, password);
+      console.log('[useAuth] Login response:', response);
+      // 检查 session_id 而非 success 字段
+      if (response.session_id) {
+        console.log('[useAuth] Login successful, setting state...');
+        setIsAuthenticated(true);
+        setUsername(response.username);
+        setRole(response.role);
+        // 持久化到 localStorage
+        localStorage.setItem('session_id', response.session_id);
+        localStorage.setItem('username', response.username);
+        const userInfo: UserInfo = {
+          username: response.username,
+          role: response.role,
+        };
+        localStorage.setItem('userInfo', JSON.stringify(userInfo));
+        console.log('[useAuth] State updated, userInfo:', userInfo);
+        return response;
+      }
+      throw new Error('Login failed: No session_id returned');
+    } finally {
+      setIsLoading(false);
+    }
+  }, []);
+
+  const register = useCallback(async (email: string, username: string, password: string) => {
+    setIsLoading(true);
+    try {
+      // 先注册
+      const registerResponse = await apiClient.register(email, username, password);
+      if (!registerResponse.success) {
+        throw new Error(registerResponse.message || 'Registration failed');
+      }
+      // 注册成功后自动登录
+      const loginResponse = await apiClient.login(email, password);
+      if (loginResponse.session_id) {
+        setIsAuthenticated(true);
+        setUsername(loginResponse.username);
+        setRole(loginResponse.role);
+        // 持久化到 localStorage
+        localStorage.setItem('session_id', loginResponse.session_id);
+        localStorage.setItem('username', loginResponse.username);
+        const userInfo: UserInfo = {
+          username: loginResponse.username,
+          role: loginResponse.role,
+        };
+        localStorage.setItem('userInfo', JSON.stringify(userInfo));
+        return { ...registerResponse, session: loginResponse };
+      }
+      throw new Error('Auto-login after registration failed');
+    } finally {
+      setIsLoading(false);
+    }
+  }, []);
+
+  const logout = useCallback(async () => {
+    setIsLoading(true);
+    try {
+      await apiClient.logout();
+      setIsAuthenticated(false);
+      setUsername(null);
+      setRole(null);
+      // 清除 localStorage
+      localStorage.removeItem('session_id');
+      localStorage.removeItem('username');
+      localStorage.removeItem('userInfo');
+    } finally {
+      setIsLoading(false);
+    }
+  }, []);
+
+  const checkStatus = useCallback(async () => {
+    try {
+      const status = await apiClient.authStatus();
+      setIsAuthenticated(status.authenticated);
+      setUsername(status.username || null);
+      setRole((status.role as UserRole) || null);
+      return status;
+    } catch (err) {
+      setIsAuthenticated(false);
+      setUsername(null);
+      setRole(null);
+      return null;
+    }
+  }, []);
+
+  return {
+    isAuthenticated,
+    username,
+    role,
+    isLoading,
+    login,
+    register,
+    logout,
+    checkStatus,
+    getMcpUrl,
+  };
+}
+
+// MCP 工具 Hook
+export function useMcpTools() {
+  const [tools, setTools] = useState<Array<{ name: string; description: string }>>([]);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const fetchTools = useCallback(async () => {
+    setIsLoading(true);
+    try {
+      const response = await apiClient.listMcpTools();
+      setTools(response.tools);
+      return response;
+    } finally {
+      setIsLoading(false);
+    }
+  }, []);
+
+  return {
+    tools,
+    isLoading,
+    fetchTools,
+  };
+}

+ 40 - 8
frontend-v2/lib/json-render-registry.tsx

@@ -8,27 +8,26 @@
 'use client';
 'use client';
 
 
 import { useState } from 'react';
 import { useState } from 'react';
-import type { ComponentSpec } from './json-render-catalog';
 
 
 // ============ React 组件实现 ============
 // ============ React 组件实现 ============
 
 
 // Card 组件
 // Card 组件
-const Card = ({ title, children, className }: any) => (
+const Card = ({ title, children, className, renderChildren }: any) => (
   <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 shadow-sm p-4 ${className || ''}`}>
   <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 shadow-sm p-4 ${className || ''}`}>
     {title && <h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">{title}</h3>}
     {title && <h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">{title}</h3>}
-    {children}
+    {renderChildren ? renderChildren(children) : null}
   </div>
   </div>
 );
 );
 
 
 // Stack 组件
 // Stack 组件
-const Stack = ({ direction = 'column', spacing = 2, align = 'start', children, className }: any) => {
+const Stack = ({ direction = 'column', spacing = 2, align = 'start', children, className, renderChildren }: any) => {
   const directionClass = direction === 'row' ? 'flex-row' : 'flex-col';
   const directionClass = direction === 'row' ? 'flex-row' : 'flex-col';
   const spacingClass = spacing > 0 ? `gap-${spacing}` : '';
   const spacingClass = spacing > 0 ? `gap-${spacing}` : '';
   const alignClass = align === 'center' ? 'items-center' : align === 'end' ? 'items-end' : align === 'stretch' ? 'items-stretch' : 'items-start';
   const alignClass = align === 'center' ? 'items-center' : align === 'end' ? 'items-end' : align === 'stretch' ? 'items-stretch' : 'items-start';
 
 
   return (
   return (
     <div className={`flex ${directionClass} ${spacingClass} ${alignClass} ${className || ''}`}>
     <div className={`flex ${directionClass} ${spacingClass} ${alignClass} ${className || ''}`}>
-      {children}
+      {renderChildren ? renderChildren(children) : null}
     </div>
     </div>
   );
   );
 };
 };
@@ -96,7 +95,7 @@ const Button = ({ label, variant = 'default', onClick, disabled = false, classNa
 };
 };
 
 
 // Input 组件
 // Input 组件
-const Input = ({ placeholder, value, onChange, disabled = false, className }: any) => {
+const Input = ({ placeholder, value, onChange, disabled = false, className, type = 'text' }: any) => {
   const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
   const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     if (onChange) {
     if (onChange) {
       try {
       try {
@@ -110,7 +109,7 @@ const Input = ({ placeholder, value, onChange, disabled = false, className }: an
 
 
   return (
   return (
     <input
     <input
-      type="text"
+      type={type}
       placeholder={placeholder}
       placeholder={placeholder}
       defaultValue={value}
       defaultValue={value}
       onChange={handleChange}
       onChange={handleChange}
@@ -388,5 +387,38 @@ export const jsonRenderRegistry: Record<string, React.ComponentType<any>> = {
   'data-table': DataTable,
   'data-table': DataTable,
 };
 };
 
 
+// ============ 辅助函数 ============
+
+/**
+ * 递归渲染子组件
+ * 用于 Card 和 Stack 组件中渲染 children 数组
+ */
+export function renderChildren(children: any): React.ReactNode {
+  if (!children) return null;
+  if (!Array.isArray(children)) return null;
+
+  return children.map((child: any, idx: number) => {
+    if (!child || typeof child !== 'object') return null;
+    if (!child.type) return <span key={idx}>{String(child)}</span>;
+
+    const componentType = child.type;
+    const Component = (jsonRenderRegistry as any)[componentType];
+
+    if (Component) {
+      // 如果组件需要渲染子组件,传入 renderChildren 函数
+      if (componentType === 'card' || componentType === 'stack') {
+        return <Component key={idx} {...child} renderChildren={renderChildren} />;
+      }
+      return <Component key={idx} {...child} />;
+    }
+
+    return (
+      <div key={idx} className="p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded border border-yellow-200 dark:border-yellow-800">
+        <p className="text-sm text-yellow-700 dark:text-yellow-300">Unknown component: {componentType}</p>
+      </div>
+    );
+  });
+}
+
 // 导出类型
 // 导出类型
-export type { ComponentSpec };
+export type { ComponentSpec } from './json-render-catalog';

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

@@ -0,0 +1,228 @@
+/**
+ * MCP 服务器配置
+ */
+export interface McpServerConfig {
+  id: string;
+  name: string;
+  url: string;
+  authType: 'none' | 'jwt';
+  loginApi?: string;
+  baseUrl?: string;
+}
+
+/**
+ * MCP 服务器配置列表
+ */
+export const MCP_SERVERS: Record<string, McpServerConfig> = {
+  'novel-translator': {
+    id: 'novel-translator',
+    name: 'Novel Translator MCP',
+    url: 'https://d8d-ai-vscode-8080-223-236-template-6-group.dev.d8d.fun/mcp',
+    authType: 'none',
+  },
+  'novel-platform-user': {
+    id: 'novel-platform-user',
+    name: 'Novel Platform User MCP',
+    url: 'https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun/mcp/',
+    authType: 'jwt',
+    loginApi: '/api/v1/auth/login',
+    baseUrl: 'https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun',
+  },
+  'novel-platform-admin': {
+    id: 'novel-platform-admin',
+    name: 'Novel Platform Admin MCP',
+    url: 'https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun/admin-mcp/',
+    authType: 'jwt',
+    loginApi: '/api/v1/auth/admin-login',
+    baseUrl: 'https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun',
+  },
+};
+
+/**
+ * MCP 登录响应
+ */
+export interface McpLoginResponse {
+  success: boolean;
+  token?: string;
+  username?: string;
+  error?: string;
+  detail?: string;
+}
+
+/**
+ * MCP Token 管理器
+ *
+ * 核心概念:
+ * - Web UI 是 MCP 测试工具,不需要全局登录
+ * - 每个 MCP 有独立的认证状态
+ * - 可以同时管理多个 MCP 的登录
+ */
+export class McpTokenManager {
+  private static instance: McpTokenManager;
+
+  private constructor() {}
+
+  static getInstance(): McpTokenManager {
+    if (!McpTokenManager.instance) {
+      McpTokenManager.instance = new McpTokenManager();
+    }
+    return McpTokenManager.instance;
+  }
+
+  /**
+   * 保存 MCP Token
+   */
+  saveToken(mcpType: string, token: string, username: string): void {
+    if (typeof window === 'undefined') return;
+    const key = `mcp_token_${mcpType}`;
+    localStorage.setItem(key, token);
+    localStorage.setItem(`mcp_token_${mcpType}_time`, Date.now().toString());
+    localStorage.setItem(`mcp_username_${mcpType}`, username);
+    // 调试:验证保存
+    console.log(`[McpTokenManager] Saved token for ${mcpType}:`, {
+      key,
+      tokenLength: token?.length,
+      username,
+      verified: localStorage.getItem(key) === token
+    });
+  }
+
+  /**
+   * 获取 MCP Token
+   */
+  getToken(mcpType: string): string | null {
+    if (typeof window === 'undefined') return null;
+    return localStorage.getItem(`mcp_token_${mcpType}`);
+  }
+
+  /**
+   * 获取 MCP 用户名
+   */
+  getUsername(mcpType: string): string | null {
+    if (typeof window === 'undefined') return null;
+    return localStorage.getItem(`mcp_username_${mcpType}`);
+  }
+
+  /**
+   * 检查 MCP 是否已登录
+   */
+  isLoggedIn(mcpType: string): boolean {
+    return !!this.getToken(mcpType);
+  }
+
+  /**
+   * 获取所有已登录的 MCP Token
+   */
+  getAllTokens(): Record<string, string> {
+    const tokens: Record<string, string> = {};
+    for (const mcpType of Object.keys(MCP_SERVERS)) {
+      const token = this.getToken(mcpType);
+      if (token) {
+        tokens[mcpType] = token;
+      }
+    }
+    return tokens;
+  }
+
+  /**
+   * 获取所有已登录的 MCP 列表
+   */
+  getLoggedInMcpList(): string[] {
+    return Object.keys(MCP_SERVERS).filter(mcpType => this.isLoggedIn(mcpType));
+  }
+
+  /**
+   * 清除 MCP Token
+   */
+  clearToken(mcpType: string): void {
+    if (typeof window === 'undefined') return;
+    localStorage.removeItem(`mcp_token_${mcpType}`);
+    localStorage.removeItem(`mcp_token_${mcpType}_time`);
+    localStorage.removeItem(`mcp_username_${mcpType}`);
+  }
+
+  /**
+   * 清除所有 MCP Token
+   */
+  clearAllTokens(): void {
+    for (const mcpType of Object.keys(MCP_SERVERS)) {
+      this.clearToken(mcpType);
+    }
+  }
+
+  /**
+   * 获取 Token 存储时长(毫秒)
+   */
+  getTokenAge(mcpType: string): number {
+    if (typeof window === 'undefined') return 0;
+    const time = localStorage.getItem(`mcp_token_${mcpType}_time`);
+    return time ? Date.now() - parseInt(time) : 0;
+  }
+
+  /**
+   * 获取 Token 剩余有效时间(假设 24 小时有效)
+   */
+  getTokenRemainingTime(mcpType: string): number {
+    const TOKEN_VALIDITY = 24 * 60 * 60 * 1000; // 24 小时
+    const age = this.getTokenAge(mcpType);
+    return Math.max(0, TOKEN_VALIDITY - age);
+  }
+
+  /**
+   * 登录 MCP 服务器
+   */
+  async loginMcp(mcpType: string, email: string, password: string): Promise<McpLoginResponse> {
+    const config = MCP_SERVERS[mcpType];
+    if (!config || config.authType !== 'jwt') {
+      return { success: false, error: '该 MCP 不需要登录' };
+    }
+
+    // 使用本地后端代理端点
+    const isAdmin = mcpType === 'novel-platform-admin';
+    const proxyUrl = isAdmin ? '/api/auth/admin-login' : '/api/auth/login';
+
+    try {
+      const response = await fetch(proxyUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ email, password }),
+      });
+
+      const data = await response.json();
+
+      console.log(`[McpTokenManager.loginMcp] Response:`, {
+        ok: response.ok,
+        status: response.status,
+        success: data.success,
+        hasToken: !!data.token,
+        username: data.username
+      });
+
+      if (response.ok && data.success && data.token) {
+        this.saveToken(mcpType, data.token, data.username || email);
+        return { success: true, token: data.token, username: data.username };
+      } else {
+        return {
+          success: false,
+          error: data.detail || data.error || '登录失败',
+        };
+      }
+    } catch (e) {
+      console.error(`[McpTokenManager.loginMcp] Error:`, e);
+      return {
+        success: false,
+        error: e instanceof Error ? e.message : '网络错误',
+      };
+    }
+  }
+
+  /**
+   * 登出 MCP 服务器
+   */
+  logoutMcp(mcpType: string): void {
+    this.clearToken(mcpType);
+  }
+}
+
+// 导出单例实例
+export const mcpTokenManager = McpTokenManager.getInstance();

+ 16 - 0
frontend-v2/next.config.js

@@ -0,0 +1,16 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  reactStrictMode: true,
+
+  // 反向代理 /api/* 到 FastAPI (localhost:8081)
+  async rewrites() {
+    return [
+      {
+        source: '/api/:path*',
+        destination: 'http://localhost:8081/api/:path*',
+      },
+    ];
+  },
+};
+
+module.exports = nextConfig;

+ 87 - 0
scripts/start_dev.sh

@@ -0,0 +1,87 @@
+#!/bin/bash
+# FastAPI 后端开发服务器启动脚本(热重载模式)
+
+set -e
+
+# 颜色定义
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+# 项目根目录
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BACKEND_DIR="$PROJECT_ROOT/backend"
+LOG_FILE="$HOME/fastapi-dev.log"
+
+# 端口配置
+PORT=${PORT:-8081}
+HOST=${HOST:-0.0.0.0}
+
+echo -e "${GREEN}=== FastAPI 开发服务器启动 ===${NC}"
+echo -e "项目目录: ${YELLOW}$PROJECT_ROOT${NC}"
+echo -e "后端目录: ${YELLOW}$BACKEND_DIR${NC}"
+echo -e "监听地址: ${YELLOW}$HOST:$PORT${NC} (内部服务)"
+echo -e "前端访问: ${YELLOW}http://localhost:8080${NC}"
+echo -e "日志文件: ${YELLOW}$LOG_FILE${NC}"
+echo ""
+
+# 检查 Python 是否安装
+if ! command -v python3 &> /dev/null; then
+    echo -e "${RED}错误: 未找到 python3${NC}"
+    exit 1
+fi
+
+# 检查 uvicorn 是否安装
+if ! python3 -c "import uvicorn" 2>/dev/null; then
+    echo -e "${YELLOW}警告: 未找到 uvicorn,正在安装...${NC}"
+    pip install uvicorn[standard] || {
+        echo -e "${RED}错误: uvicorn 安装失败${NC}"
+        exit 1
+    }
+fi
+
+# 检查端口是否被占用
+if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null 2>&1; then
+    echo -e "${YELLOW}警告: 端口 $PORT 已被占用${NC}"
+    echo -e "正在尝试停止占用端口的进程..."
+    lsof -ti:$PORT | xargs kill -9 2>/dev/null || true
+    sleep 1
+fi
+
+# 切换到后端目录
+cd "$BACKEND_DIR"
+
+# 启动服务器(热重载模式)
+echo -e "${GREEN}启动服务器(热重载模式)...${NC}"
+echo -e "${GREEN}代码修改将自动重启服务${NC}"
+echo ""
+
+python3 -m uvicorn app_fastapi:app \
+    --host $HOST \
+    --port $PORT \
+    --reload \
+    --log-level info \
+    > "$LOG_FILE" 2>&1 &
+
+PID=$!
+echo $PID > "$HOME/fastapi-dev.pid"
+
+sleep 2
+
+# 检查服务是否启动成功
+if ps -p $PID > /dev/null; then
+    echo -e "${GREEN}✓ 服务启动成功!${NC}"
+    echo -e "  PID: ${YELLOW}$PID${NC}"
+    echo -e "  内网访问: ${YELLOW}http://localhost:$PORT${NC}"
+    echo -e "  外网访问: ${YELLOW}https://d8d-ai-vscode-8080-223-240-template-6-group.dev.d8d.fun/${NC}"
+    echo ""
+    echo -e "${YELLOW}查看日志: tail -f $LOG_FILE${NC}"
+    echo -e "${YELLOW}停止服务: kill $PID${NC}"
+    echo ""
+    echo -e "${GREEN}热重载已启用,修改代码后自动重启${NC}"
+else
+    echo -e "${RED}✗ 服务启动失败${NC}"
+    echo -e "查看日志: ${YELLOW}cat $LOG_FILE${NC}"
+    exit 1
+fi

+ 32 - 0
scripts/stop_dev.sh

@@ -0,0 +1,32 @@
+#!/bin/bash
+# 停止 FastAPI 开发服务器
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+PID_FILE="$HOME/fastapi-dev.pid"
+
+if [ -f "$PID_FILE" ]; then
+    PID=$(cat "$PID_FILE")
+    if ps -p $PID > /dev/null 2>&1; then
+        echo -e "${YELLOW}停止 FastAPI 服务 (PID: $PID)...${NC}"
+        kill $PID
+        rm -f "$PID_FILE"
+        echo -e "${GREEN}✓ 服务已停止${NC}"
+    else
+        echo -e "${RED}✗ 进程不存在 (PID: $PID)${NC}"
+        rm -f "$PID_FILE"
+    fi
+else
+    echo -e "${YELLOW}查找运行中的 FastAPI 进程...${NC}"
+    PIDS=$(ps aux | grep -E "uvicorn.*app_fastapi" | grep -v grep | awk '{print $2}')
+    if [ -n "$PIDS" ]; then
+        echo -e "${YELLOW}停止进程: $PIDS${NC}"
+        echo $PIDS | xargs kill 2>/dev/null
+        echo -e "${GREEN}✓ 服务已停止${NC}"
+    else
+        echo -e "${YELLOW}没有运行中的 FastAPI 服务${NC}"
+    fi
+fi