Browse Source

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

翻译卡片优化 (json-render-catalog.tsx):
- 简化只显示译文和使用的术语
- 移除源语言/目标语言等多余字段
- 修复复制按钮反馈问题

新增组件交互系统:
- action-context.tsx: 新增 ActionContext 和 ActionProvider
- 支持 emit 事件回调机制
- 实现工具调用结果反馈

主页面更新 (app/page.tsx):
- 添加 ActionProvider 包装器
- 集成事件系统支持
- 支持组件交互功能

组件注册优化 (json-render-registry.tsx):
- 更新 TranslationResult 组件接口
- 适配新的事件系统

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude AI 11 hours ago
parent
commit
d36d4f6a54

+ 176 - 0
frontend-v2/app/page.tsx

@@ -0,0 +1,176 @@
+/**
+ * 主页面 - 聊天界面
+ *
+ * 集成 json-render 生成式 UI
+ * 支持动态渲染 MCP 工具调用结果
+ * 支持组件交互事件(ActionProvider)
+ */
+'use client';
+
+import React, { useState, useCallback, useEffect, 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 type { ComponentSpec } from '@/lib/json-render-catalog';
+import { ActionProvider } from '@/lib/action-context';
+
+// 消息类型扩展
+interface ExtendedMessage {
+  role: 'user' | 'assistant';
+  content: string;
+  specs?: ComponentSpec[]; // json-render 组件 specs
+  toolCalls?: Array<{ tool: string; result: unknown }>;
+}
+
+export default function Home() {
+  const [history, setHistory] = useState<ExtendedMessage[]>([]);
+  const { sendMessage, isLoading, response, toolCalls, specs, error, abort } = useChat();
+  const pendingMessageRef = useRef<string | null>(null);
+
+  // 当加载完成时,将完整的助手消息添加到历史记录
+  useEffect(() => {
+    console.log('[useEffect] Triggered:', {
+      isLoading,
+      pendingMessage: pendingMessageRef.current,
+      responseLength: response?.length || 0,
+      responsePreview: response?.substring(0, 100),
+      specsCount: specs?.length || 0,
+      toolCallsCount: toolCalls?.length || 0,
+      hasError: !!error,
+    });
+    if (!isLoading && pendingMessageRef.current) {
+      // 即使没有响应内容,也添加消息(可能只有工具调用结果)
+      const assistantMessage = {
+        role: 'assistant' as const,
+        content: response || (error ? `⚠️ ${error}` : ''),
+        specs: specs && specs.length > 0 ? specs : undefined,
+        toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+      };
+      console.log('[useEffect] Adding assistant message to history:', assistantMessage);
+      setHistory((prev) => [...prev, assistantMessage]);
+      pendingMessageRef.current = null;
+    }
+  }, [isLoading, response, toolCalls, specs, error]);
+
+  const handleSend = async (message: string) => {
+    // 添加用户消息到历史记录
+    setHistory((prev) => [...prev, { role: 'user', content: message }]);
+    pendingMessageRef.current = message;
+
+    // 开始流式对话(不等待完成,由 useEffect 处理完成后的消息)
+    sendMessage(message, history);
+  };
+
+  const handleAbort = useCallback(() => {
+    abort();
+    // 将当前的部分响应添加到历史记录
+    if (pendingMessageRef.current && (response || toolCalls.length > 0 || specs.length > 0)) {
+      setHistory((prev) => [
+        ...prev,
+        {
+          role: 'assistant',
+          content: response,
+          specs: specs && specs.length > 0 ? specs : undefined,
+          toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+        },
+      ]);
+      pendingMessageRef.current = null;
+    }
+  }, [abort, response, toolCalls, specs]);
+
+  // 创建 action context 用于组件交互
+  const actionContext = useCallback(() => ({
+    sendMessage: handleSend,
+  }), [handleSend]);
+
+  return (
+    <div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900">
+      <Header />
+
+      <main className="flex-1 overflow-hidden flex">
+        {/* 聊天区域 */}
+        <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>
+                  </div>
+                </div>
+              </div>
+            ) : (
+              <div className="space-y-3 md:space-y-4 pb-20 md:pb-4">
+                {history.map((msg, idx) => {
+                  console.log(`[Render] Message ${idx}:`, msg);
+                  return (
+                    <div key={idx} className="px-1">
+                      <ChatMessage role={msg.role} content={msg.content} />
+                      {/* 渲染 json-render specs - 使用 ActionProvider 包装 */}
+                      {msg.specs && msg.specs.length > 0 && (
+                        <ActionProvider actions={actionContext()}>
+                          <div className="mt-2 md:mt-3 ml-2 md:ml-4 mr-2">
+                            <JsonRenderer specs={msg.specs} className="max-w-full md:max-w-2xl" />
+                          </div>
+                        </ActionProvider>
+                      )}
+                      {/* 回退:渲染 toolCalls (用于调试) */}
+                      {!msg.specs && msg.toolCalls && msg.toolCalls.length > 0 && (
+                        <ActionProvider actions={actionContext()}>
+                          <div className="mt-2 md:mt-3 ml-2 md:ml-4 mr-2">
+                            <JsonRenderer toolCalls={msg.toolCalls} className="max-w-full md:max-w-2xl" />
+                          </div>
+                        </ActionProvider>
+                      )}
+                    </div>
+                  );
+                })}
+                {isLoading && response && (
+                  <ChatMessage role="assistant" content={response} isLoading />
+                )}
+                {/* 加载中的工具调用结果 */}
+                {isLoading && specs.length > 0 && (
+                  <ActionProvider actions={actionContext()}>
+                    <div className="ml-2 md:ml-4 mt-2 md:mt-3 mr-2">
+                      <JsonRenderer specs={specs} isLoading className="max-w-full md:max-w-2xl" />
+                    </div>
+                  </ActionProvider>
+                )}
+                {/* 加载中的工具调用 (回退) */}
+                {isLoading && specs.length === 0 && toolCalls.length > 0 && (
+                  <ActionProvider actions={actionContext()}>
+                    <div className="ml-2 md:ml-4 mt-2 md:mt-3 mr-2">
+                      <JsonRenderer toolCalls={toolCalls} isLoading className="max-w-full md:max-w-2xl" />
+                    </div>
+                  </ActionProvider>
+                )}
+                {error && (
+                  <div className="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-3 py-2 md:px-4 md:py-3 rounded-lg mx-1">
+                    <p className="text-sm">{error}</p>
+                  </div>
+                )}
+              </div>
+            )}
+          </div>
+
+          <div className="border-t dark:border-gray-700 p-2 md:p-4 pb-safe bg-white dark:bg-gray-800">
+            <ChatInput onSend={handleSend} isLoading={isLoading} onAbort={handleAbort} />
+          </div>
+        </div>
+
+        {/* 工具调用面板 - 仅在桌面端显示侧边栏 */}
+        {(toolCalls.length > 0 || isLoading) && (
+          <ToolCallPanel toolCalls={toolCalls} isLoading={isLoading} />
+        )}
+      </main>
+    </div>
+  );
+}

+ 95 - 0
frontend-v2/lib/action-context.tsx

@@ -0,0 +1,95 @@
+/**
+ * ActionContext - json-render 组件交互事件处理
+ *
+ * 提供 emit 函数供组件触发交互事件
+ */
+
+'use client';
+
+import { createContext, useContext, useCallback } from 'react';
+
+/**
+ * Action Context 类型
+ */
+interface ActionContextValue {
+  emit: (eventName: string, payload?: any) => void;
+}
+
+/**
+ * ActionContext - 用于在组件树中传递 emit 函数
+ */
+export const ActionContext = createContext<ActionContextValue | null>(null);
+
+/**
+ * ActionProvider 处理 json-render 组件的交互事件
+ * 组件通过 emit() 触发事件,ActionProvider 执行相应的处理函数
+ */
+interface ActionProviderActions {
+  sendMessage: (message: string) => void;
+}
+
+interface ActionProviderProps {
+  children: React.ReactNode;
+  actions: ActionProviderActions;
+}
+
+/**
+ * ActionProvider 组件 - 包装 JsonRenderer,提供事件处理能力
+ */
+export function ActionProvider({ children, actions }: ActionProviderProps) {
+  /**
+   * emit 函数 - 组件调用此函数触发事件
+   * @param eventName - 事件名称
+   * @param payload - 事件负载
+   */
+  const emit = useCallback((eventName: string, payload?: any) => {
+    console.log('[ActionProvider] Event emitted:', eventName, payload);
+
+    switch (eventName) {
+      case 'sendMessage':
+        // 发送消息到聊天
+        const message = typeof payload === 'string' ? payload : payload?.message || '';
+        if (message) {
+          actions.sendMessage(message);
+        }
+        break;
+
+      case 'selectNovel':
+        // 选择小说,发送查看小说的请求
+        if (payload?.title || payload?.id) {
+          const novelTitle = payload.title || payload.id;
+          actions.sendMessage(`查看小说 ${novelTitle}`);
+        }
+        break;
+
+      case 'copy':
+        // 复制文本到剪贴板
+        if (payload?.text) {
+          navigator.clipboard.writeText(payload.text);
+        }
+        break;
+
+      default:
+        console.warn('[ActionProvider] Unknown event:', eventName);
+    }
+  }, [actions]);
+
+  return (
+    <ActionContext.Provider value={{ emit }}>
+      {children}
+    </ActionContext.Provider>
+  );
+}
+
+/**
+ * Hook - 在组件中获取 emit 函数
+ * 如果不在 ActionProvider 内,返回 no-op emit
+ */
+export function useActionEmit() {
+  const context = useContext(ActionContext);
+  if (!context) {
+    console.warn('[useActionEmit] ActionContext not found, returning no-op emit');
+    return { emit: () => {} };
+  }
+  return context;
+}

+ 35 - 2
frontend-v2/lib/json-render-catalog.tsx

@@ -3,6 +3,36 @@
  *
  * 定义 MCP 工具专用组件和基础 shadcn 组件
  * 用于生成式 UI (Generative UI) 渲染
+ *
+ * ## 交互功能
+ *
+ * 组件支持通过 `emit` 函数触发交互事件:
+ *
+ * ```typescript
+ * // 在组件中接收 emit 函数
+ * const MyComponent = ({ emit }: any) => (
+ *   <button onClick={() => emit('eventName', payload)}>
+ *     Click Me
+ *   </button>
+ * );
+ * ```
+ *
+ * ### 支持的事件类型
+ *
+ * | 事件名 | 负载 | 说明 |
+ * |--------|------|------|
+ * | `sendMessage` | `{ message: string }` | 发送消息到聊天 |
+ * | `selectNovel` | `{ id, title, ... }` | 选择小说 |
+ * | `copy` | `{ text: string }` | 复制文本到剪贴板 |
+ *
+ * ### 组件交互示例
+ *
+ * ```typescript
+ * // NovelList 组件 - 点击小说卡片自动发送消息
+ * <div onClick={() => emit('selectNovel', { id: novel.id, title: novel.title })}>
+ *   {novel.title}
+ * </div>
+ * ```
  */
 
 'use client';
@@ -50,7 +80,9 @@ export const ButtonSchema = z.object({
   type: z.literal('button'),
   label: z.string(),
   variant: z.enum(['default', 'primary', 'secondary', 'ghost', 'danger']).optional(),
-  onClick: z.string().optional(), // JavaScript 代码字符串
+  onClick: z.string().optional(), // JavaScript 代码字符串(保留兼容)
+  action: z.string().optional(),   // 交互事件名:使用 emit 触发
+  actionPayload: z.any().optional(), // 事件负载数据
   disabled: z.boolean().optional(),
   className: z.string().optional(),
 });
@@ -101,7 +133,8 @@ export const NovelListSchema = z.object({
     chapterCount: z.number().optional(),
     tags: z.array(z.string()).optional(),
   })),
-  onSelect: z.string().optional(), // 回调函数名
+  onSelect: z.string().optional(), // 回调函数名(保留兼容)
+  action: z.string().optional(),   // 交互事件:点击卡片后触发的事件
   className: z.string().optional(),
 });
 

+ 27 - 9
frontend-v2/lib/json-render-registry.tsx

@@ -60,8 +60,8 @@ const Text = ({ content, variant = 'body', className }: any) => {
   return <p className={`${variantClass} ${className || ''}`}>{content}</p>;
 };
 
-// Button 组件
-const Button = ({ label, variant = 'default', onClick, disabled = false, className }: any) => {
+// Button 组件 - 支持交互事件
+const Button = ({ label, variant = 'default', onClick, action, actionPayload, disabled = false, className, emit }: any) => {
   const variantClasses: Record<string, string> = {
     default: 'bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-200',
     primary: 'bg-blue-600 hover:bg-blue-700 text-white',
@@ -72,7 +72,16 @@ const Button = ({ label, variant = 'default', onClick, disabled = false, classNa
   const variantClass = variantClasses[variant] || variantClasses.default;
 
   const handleClick = () => {
-    if (onClick && !disabled) {
+    if (disabled) return;
+
+    // 优先使用 action 事件(emit)
+    if (action && emit) {
+      emit(action, actionPayload);
+      return;
+    }
+
+    // 回退到 onClick 代码执行
+    if (onClick) {
       try {
         // 安全执行 onClick 代码
         const fn = new Function('event', onClick);
@@ -145,12 +154,17 @@ const Separator = ({ orientation = 'horizontal', className }: any) => {
 
 // ============ MCP 专用组件实现 ============
 
-// TranslationResult 组件 - 翻译结果展示
-const TranslationResult = ({ translated, termsUsed, className }: any) => {
+// TranslationResult 组件 - 翻译结果展示(支持 emit 事件)
+const TranslationResult = ({ translated, termsUsed, className, emit }: any) => {
   const [copied, setCopied] = useState(false);
 
   const handleCopy = () => {
-    navigator.clipboard.writeText(translated);
+    // 使用 emit 触发复制事件(如果可用),否则直接调用 clipboard API
+    if (emit) {
+      emit('copy', { text: translated });
+    } else {
+      navigator.clipboard.writeText(translated);
+    }
     setCopied(true);
     setTimeout(() => setCopied(false), 2000);
   };
@@ -206,11 +220,15 @@ const TranslationResult = ({ translated, termsUsed, className }: any) => {
   );
 };
 
-// NovelList 组件
-const NovelList = ({ novels, className }: any) => (
+// NovelList 组件 - 支持点击交互
+const NovelList = ({ novels, className, emit }: any) => (
   <div className={`novel-list space-y-3 ${className || ''}`}>
     {novels.map((novel: any) => (
-      <div key={novel.id} className="bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-4 hover:shadow-md transition-shadow cursor-pointer">
+      <div
+        key={novel.id}
+        onClick={() => emit && emit('selectNovel', { id: novel.id, title: novel.title, ...novel })}
+        className="bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all cursor-pointer"
+      >
         <h4 className="font-semibold text-lg text-gray-900 dark:text-white mb-1">{novel.title}</h4>
         {novel.author && <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">By {novel.author}</p>}
         {novel.description && <p className="text-sm text-gray-600 dark:text-gray-300 mb-2 line-clamp-2">{novel.description}</p>}