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

fix: 修复 json-render Provider 问题,SpecStream 流式渲染正常工作

- 使用 JSONUIProvider 包装 Renderer 组件
- 在 page.tsx 中添加 specs 到 useChat 解构
- 将 specs prop 传递给 ChatMessage 组件
- 添加 catalog、registry 等组件注册相关文件
- 添加 API 路由用于组件 prompt

Co-Authored-By: Claude <noreply@anthropic.com>
Claude AI 3 недель назад
Родитель
Сommit
26c8690374

+ 91 - 0
frontend-v2/app/api/components-prompt/route.ts

@@ -0,0 +1,91 @@
+/**
+ * API Route: 获取 json-render 组件提示词
+ *
+ * 返回 catalog.prompt() 生成的提示词
+ * 前端可以在初始化时调用此 API 获取最新提示词
+ */
+import { NextResponse } from 'next/server';
+import { readFile } from 'fs/promises';
+import { join } from 'path';
+
+// 缓存提示词(5分钟过期)
+let cachedPrompt: string | null = null;
+let cacheTime: number = 0;
+const CACHE_TTL = 5 * 60 * 1000; // 5 分钟
+
+export async function GET() {
+  try {
+    // 检查缓存
+    const now = Date.now();
+    if (cachedPrompt && (now - cacheTime) < CACHE_TTL) {
+      return NextResponse.json({
+        success: true,
+        prompt: cachedPrompt,
+        cached: true,
+        length: cachedPrompt.length,
+      });
+    }
+
+    // 读取预生成的提示词文件
+    const promptPath = join(process.cwd(), 'lib/catalog/generated-prompt.txt');
+    const prompt = await readFile(promptPath, 'utf-8');
+
+    // 更新缓存
+    cachedPrompt = prompt;
+    cacheTime = now;
+
+    return NextResponse.json({
+      success: true,
+      prompt,
+      cached: false,
+      length: prompt.length,
+      generatedAt: new Date().toISOString(),
+    });
+  } catch (error) {
+    console.error('Failed to read components prompt:', error);
+    
+    // 返回 fallback 提示词
+    const fallbackPrompt = generateFallbackPrompt();
+    
+    return NextResponse.json({
+      success: false,
+      prompt: fallbackPrompt,
+      error: 'Failed to read generated prompt, using fallback',
+      length: fallbackPrompt.length,
+    });
+  }
+}
+
+/**
+ * 生成 fallback 提示词(当文件读取失败时使用)
+ */
+function generateFallbackPrompt(): string {
+  return `## 可用的 json-render 组件
+
+你可以使用以下组件来展示结构化数据。
+
+### 基础组件
+- card: 卡片容器
+- stack: 弹性布局
+- heading: 标题 (h1-h6)
+- text: 文本
+- button: 按钮
+- badge: 徽章
+- code-block: 代码块
+
+### MCP 专用组件
+- translation-result: 翻译结果
+- novel-list: 小说列表
+- chapter-reader: 章节阅读器
+- tool-call: 工具调用状态
+
+组件格式示例:
+\`\`\`json
+{
+  "type": "card",
+  "title": "标题",
+  "children": []
+}
+\`\`\`
+`;
+}

+ 2 - 2
frontend-v2/app/page.tsx

@@ -105,7 +105,7 @@ const WELCOME_SPEC: ComponentSpec = {
 
 export default function Home() {
   const [history, setHistory] = useState<Message[]>([]);
-  const { sendMessage, isLoading, response, error, abort } = useChat();
+  const { sendMessage, isLoading, response, error, abort, specs } = useChat();
   const pendingMessageRef = useRef<string | null>(null);
 
   // 当加载完成时,将完整的助手消息添加到历史记录
@@ -187,7 +187,7 @@ export default function Home() {
                     );
                   })}
                   {isLoading && response && (
-                    <ChatMessage role="assistant" content={response} isLoading />
+                    <ChatMessage role="assistant" content={response} specs={specs} isLoading />
                   )}
                   {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">

+ 127 - 161
frontend-v2/components/ChatMessage.tsx

@@ -1,225 +1,191 @@
 /**
  * 聊天消息组件 - 支持 Markdown 渲染和 JSON 组件混排
  *
- * 功能:
- * - 使用 react-markdown 渲染 Markdown
- * - 自动检测 JSON 代码块,如果是有效的 json-render spec,就地渲染为组件
- * - 支持代码高亮和复制功能
- * - 支持 GitHub Flavored Markdown (GFM)
+ * 更新:使用官方 @json-render/core API
+ * - 支持从 props 直接接收编译后的 specs
+ * - 使用官方 Renderer 渲染组件
  */
 'use client';
 
 import { useEffect, useMemo } from 'react';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
-import { jsonRenderRegistry } from '@/lib/json-render-registry';
-import type { ComponentSpec } from '@/lib/json-render-catalog';
+import { Renderer, JSONUIProvider } from '@json-render/react';
+import { compileSpecStream, parseSpecStreamLine } from '@json-render/core';
+import { registry } from '@/lib/registry';
 import { useActionEmit } from '@/lib/action-context';
+import type { Spec } from '@json-render/core';
 
 interface ChatMessageProps {
   role: 'user' | 'assistant';
   content: string;
   isLoading?: boolean;
+  specs?: Spec[];  // 新增:接收编译后的 specs
 }
 
 /**
- * 验证 JSON 数据是否为有效的 json-render 组件 spec
- *
- * 检查条件:
- * 1. 是对象且包含 type 字段
- * 2. type 字段值在 jsonRenderRegistry 中存在
+ * 检查文本是否为 JSON Patch 格式 (SpecStream)
  */
-function isValidJsonRenderSpec(data: unknown): data is ComponentSpec {
-  if (!data || typeof data !== 'object' || Array.isArray(data)) {
-    return false;
-  }
-
-  const obj = data as Record<string, unknown>;
-  const type = obj.type;
-
-  if (typeof type !== 'string') {
-    return false;
+function isSpecStreamFormat(text: string): boolean {
+  const lines = text.split('\n').filter(l => l.trim());
+  if (lines.length === 0) return false;
+
+  let patchCount = 0;
+  for (const line of lines.slice(0, 5)) {
+    const patch = parseSpecStreamLine(line);
+    if (patch) patchCount++;
   }
 
-  // 检查 type 是否在注册表中
-  return type in jsonRenderRegistry;
+  return patchCount >= 2;
 }
 
 /**
- * 自定义代码块渲染器
- *
- * 逻辑:
- * 1. 检测是否是 JSON 代码块
- * 2. 尝试解析 JSON
- * 3. 如果是有效的 json-render spec,渲染为组件
- * 4. 否则渲染为普通代码块
+ * 从 SpecStream 格式中编译出完整的 spec
  */
-function CodeBlock({
-  inline,
-  className,
-  children,
-  emit,
-  ...props
-}: {
-  inline?: boolean;
-  className?: string;
-  children?: React.ReactNode;
-  emit?: (eventName: string, payload?: any) => void;
-  [key: string]: any;
-}) {
-  const code = String(children).replace(/\n$/, '');
-
-  // 内联代码直接渲染
-  if (inline) {
-    return (
-      <code
-        className="bg-gray-100 dark:bg-gray-900 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800 dark:text-gray-200"
-        {...props}
-      >
-        {children}
-      </code>
-    );
+function compileSpecFromStream(text: string): Spec | null {
+  try {
+    const spec = compileSpecStream(text as any);
+    console.log('[compileSpecFromStream] Compiled spec:', spec);
+    return spec;
+  } catch (e) {
+    console.error('[compileSpecFromStream] Failed to compile:', e);
+    return null;
   }
+}
 
-  // 提取语言标识
-  const match = /language-(\w+)/.exec(className || '');
-  const language = match ? match[1] : 'text';
-
-  // 检测是否是 JSON 代码块
-  const isJson = language === 'json' || language === 'jsonc';
-
-  if (isJson) {
-    try {
-      const parsed = JSON.parse(code);
-
-      // 检查是否是有效的 json-render spec
-      if (isValidJsonRenderSpec(parsed)) {
-        // 使用 JsonRenderer 渲染,传入 emit 函数
-        const JsonRenderer = require('@/components/JsonRenderer').default;
-
-        // 包装 JsonRenderer,提供 emit 函数
-        const SpecComponent = () => {
-          // 创建一个内部组件,使用 ActionContext
-          const { ActionContext } = require('@/lib/action-context');
-          const { useContext } = require('react');
+/**
+ * 从编译后的 spec 中提取可渲染的元素
+ * 对于 flat 格式 {root, elements},返回 root 指向的元素
+ */
+function extractRenderableElements(spec: Spec): Spec[] {
+  const elements: Spec[] = [];
 
-          function WrappedSpec() {
-            const actionContext = useContext(ActionContext);
-            return <JsonRenderer spec={parsed as ComponentSpec} />;
-          }
+  if (!spec || typeof spec !== 'object') return elements;
 
-          return <WrappedSpec />;
-        };
+  // 如果 spec 有 root 和 elements 字段(flat 格式)
+  // 需要渲染整个 spec 对象,让 Renderer 根据 root 找到对应元素
+  if (spec.root && spec.elements && typeof spec.elements === 'object') {
+    // 返回完整的 flat spec,Renderer 会处理
+    elements.push(spec);
+    return elements;
+  }
 
-        return <SpecComponent />;
+  // 如果 spec 有 elements 字段但没有 root
+  if (spec.elements && typeof spec.elements === 'object') {
+    for (const [key, element] of Object.entries(spec.elements)) {
+      if (element && typeof element === 'object' && 'type' in element) {
+        elements.push(element as Spec);
       }
+    }
+  }
 
-      // 如果是数组,检查是否每个元素都是有效的 spec
-      if (Array.isArray(parsed) && parsed.length > 0) {
-        const allValidSpecs = parsed.every(isValidJsonRenderSpec);
-        if (allValidSpecs) {
-          const JsonRenderer = require('@/components/JsonRenderer').default;
-
-          const SpecsComponent = () => {
-            const { ActionContext } = require('@/lib/action-context');
-            const { useContext } = require('react');
-
-            function WrappedSpecs() {
-              const actionContext = useContext(ActionContext);
-              return <JsonRenderer specs={parsed as ComponentSpec[]} />;
-            }
+  // 如果 spec 本身就是一个组件
+  if ('type' in spec && spec.type) {
+    elements.push(spec);
+  }
 
-            return <WrappedSpecs />;
-          };
+  return elements;
+}
 
-          return <SpecsComponent />;
-        }
-      }
-    } catch {
-      // JSON 解析失败,继续渲染为普通代码块
-    }
+function CodeBlock({ inline, className, children, ...props }: any) {
+  const code = String(children).replace(/\n$/, '');
+  if (inline) {
+    return <code className="bg-gray-100 dark:bg-gray-900 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>{children}</code>;
   }
-
-  // 渲染为普通代码块
+  const match = /language-(\w+)/.exec(className || '');
+  const language = match ? match[1] : 'text';
   return (
-    <div className="code-block bg-gray-900 dark:bg-gray-950 rounded-lg overflow-x-auto my-2">
-      <div className="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-gray-900 border-b border-gray-700">
+    <div className="code-block bg-gray-900 rounded-lg overflow-x-auto my-2">
+      <div className="flex items-center justify-between px-4 py-2 bg-gray-800 border-b border-gray-700">
         <span className="text-xs text-gray-400 uppercase">{language}</span>
-        <button
-          onClick={() => navigator.clipboard.writeText(code)}
-          className="text-xs text-gray-400 hover:text-white transition-colors"
-        >
-          复制
-        </button>
+        <button onClick={() => navigator.clipboard.writeText(code)} className="text-xs text-gray-400 hover:text-white">复制</button>
       </div>
-      <pre className="p-4 text-sm text-gray-100 font-mono overflow-x-auto">
-        <code {...props}>{children}</code>
-      </pre>
+      <pre className="p-4 text-sm text-gray-100 font-mono overflow-x-auto"><code {...props}>{children}</code></pre>
     </div>
   );
 }
 
-/**
- * 自定义段落渲染器
- * 处理段落中的换行
- */
-function Paragraph({ children }: { children?: React.ReactNode }) {
-  return (
-    <p className="my-2 leading-relaxed">
-      {children}
-    </p>
-  );
+function Paragraph({ children }: any) {
+  return <p className="my-2 leading-relaxed">{children}</p>;
 }
 
-/**
- * 聊天消息组件
- */
-export default function ChatMessage({ role, content, isLoading }: ChatMessageProps) {
+export default function ChatMessage({ role, content, isLoading, specs: propSpecs }: ChatMessageProps) {
   const isUser = role === 'user';
   const { emit } = useActionEmit();
 
-  // 调试日志
+  // 优先使用传入的 specs,否则从 content 编译
+  const compiledSpecs = useMemo(() => {
+    // 如果 props 传入了 specs,直接使用
+    if (propSpecs && propSpecs.length > 0) {
+      console.log('[ChatMessage] Using propSpecs:', propSpecs);
+      return propSpecs;
+    }
+
+    if (isUser || !content) return [];
+
+    // 回退:从 content 编译
+    if (isSpecStreamFormat(content)) {
+      console.log('[ChatMessage] Detected SpecStream format, compiling...');
+      const spec = compileSpecFromStream(content);
+      if (spec) {
+        const elements = extractRenderableElements(spec);
+        console.log('[ChatMessage] Extracted', elements.length, 'elements from content');
+        return elements;
+      }
+    }
+
+    return [];
+  }, [content, isUser, propSpecs]);
+
   useEffect(() => {
     if (!isUser) {
-      console.log('[ChatMessage] Rendering assistant message:', {
-        contentLength: content?.length || 0,
+      console.log('[ChatMessage] Rendering:', {
+        hasPropSpecs: !!(propSpecs && propSpecs.length > 0),
+        isSpecStream: isSpecStreamFormat(content || ''),
+        compiledSpecsCount: compiledSpecs.length,
         contentPreview: content?.substring(0, 100),
-        isLoading,
       });
     }
-  }, [content, isLoading, isUser]);
+  }, [content, isUser, compiledSpecs, propSpecs]);
 
-  return (
-    <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
-      <div
-        className={`message-bubble ${
-          isUser ? 'user-message' : 'assistant-message'
-        }`}
-      >
-        {!isUser && (
-          <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
-            AI 助手
+  // 如果有编译出的 specs,渲染组件
+  if (compiledSpecs.length > 0) {
+    return (
+      <div className="flex justify-start">
+        <div className="message-bubble assistant-message max-w-full">
+          <div className="text-xs text-gray-500 dark:text-gray-400 mb-2">AI 助手</div>
+          <div className="space-y-4">
+            <JSONUIProvider registry={registry}>
+              {compiledSpecs.map((spec, i) => {
+                try {
+                  console.log('[ChatMessage] Rendering spec:', spec);
+                  return <Renderer key={i} registry={registry} spec={spec} />;
+                } catch (e) {
+                  console.error('[ChatMessage] Render error:', e);
+                  return <div key={i} className="p-2 bg-red-50 rounded text-sm text-red-600">渲染错误: {String(e)}</div>;
+                }
+              })}
+            </JSONUIProvider>
+            {isLoading && <span className="inline-block ml-1 animate-pulse">▊</span>}
           </div>
-        )}
+        </div>
+      </div>
+    );
+  }
 
-        {/* Markdown 渲染区域(仅 assistant 消息) */}
+  // 普通 Markdown 渲染
+  return (
+    <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
+      <div className={`message-bubble ${isUser ? 'user-message' : 'assistant-message'}`}>
+        {!isUser && <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">AI 助手</div>}
         {!isUser ? (
           <div className="prose prose-sm dark:prose-invert max-w-none">
-            <ReactMarkdown
-              remarkPlugins={[remarkGfm]}
-              components={{
-                code: (props) => <CodeBlock {...props} emit={emit} />,
-                p: Paragraph,
-              }}
-            >
+            <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: CodeBlock, p: Paragraph }}>
               {content || ''}
             </ReactMarkdown>
-            {isLoading && (
-              <span className="inline-block ml-1 animate-pulse">▊</span>
-            )}
+            {isLoading && <span className="inline-block ml-1 animate-pulse">▊</span>}
           </div>
         ) : (
-          /* 用户消息 - 纯文本显示 */
           <div className="whitespace-pre-wrap break-words">
             {content || <span className="text-gray-400 italic">暂无内容</span>}
           </div>

+ 293 - 249
frontend-v2/components/JsonRenderer.tsx

@@ -1,303 +1,347 @@
 /**
- * JsonRenderer 组件
+ * JsonRenderer - 使用官方 @json-render/react API 的渲染器
  *
- * 基于 Vercel Labs json-render 的生成式 UI 渲染器
- * 接收 AI 生成的 spec 并渲染为 React 组件
- * 支持组件交互事件(通过 ActionProvider 传递 emit 函数)
+ * 功能:
+ * - 使用官方 Renderer 组件渲染 json-render spec
+ * - 支持单个 spec 和 specs 数组
+ * - 支持流式渲染(SSE)
+ * - 兼容旧格式的 ComponentSpec
+ *
+ * 使用方式:
+ * ```tsx
+ * // 渲染单个 spec(旧格式)
+ * <JsonRenderer spec={{ type: 'card', title: 'Hello' }} />
+ *
+ * // 渲染多个 specs(旧格式)
+ * <JsonRenderer specs={[spec1, spec2]} />
+ *
+ * // 渲染官方格式 spec
+ * <JsonRenderer spec={officialSpec} />
+ * ```
  */
 
 'use client';
 
-import React, { useState, useMemo, useContext } from 'react';
-import { jsonRenderRegistry, renderChildren } from '@/lib/json-render-registry';
-import type { ComponentSpec } from '@/lib/json-render-catalog';
-import { ActionContext } from '@/lib/action-context'; // 导入 ActionContext
+import { useMemo } from 'react';
+import {
+  Renderer,
+  JSONUIProvider,
+  type Spec,
+} from '@json-render/react';
+import type { UIElement } from '@json-render/core';
+import { registry } from '@/lib/registry';
+
+// ============ Types ============
+
+/**
+ * 旧格式组件 spec 类型
+ * 旧格式: { type: 'card', title: '...', children: [...] }
+ */
+export interface LegacyComponentSpec {
+  type: string;
+  children?: LegacyComponentSpec[];
+  [key: string]: unknown;
+}
+
+/**
+ * JsonRenderer Props
+ */
+export interface JsonRendererProps {
+  /** 单个组件 spec(支持旧格式和官方格式) */
+  spec?: LegacyComponentSpec | Spec | null;
+  /** 多个组件 spec 数组 */
+  specs?: LegacyComponentSpec[] | Spec[];
+  /** 可选样式类 */
+  className?: string;
+  /** 事件处理函数 */
+  onAction?: (actionName: string, params?: Record<string, unknown>) => void;
+  /** 是否正在加载 */
+  loading?: boolean;
+}
+
+/**
+ * StreamingJsonRenderer Props - 用于 SSE 流式渲染
+ */
+export interface StreamingJsonRendererProps extends JsonRendererProps {
+  /** 流式数据 */
+  specs?: LegacyComponentSpec[];
+}
+
+// ============ Helper Functions ============
 
-// 避免直接导入 json-render 类型,使用 any 来处理类型兼容性
-type AnySpec = Record<string, any>;
+/**
+ * 生成唯一 key
+ */
+let keyCounter = 0;
+function generateKey(): string {
+  return `el_${++keyCounter}`;
+}
 
 /**
- * 从 markdown 代码块中提取 JSON
+ * 将旧的嵌套格式 ComponentSpec 转换为扁平化的官方 Spec 格式
  *
- * 支持以下格式:
- * - ```json {...} ```
- * - ``` {...} ```
- * - 直接的 JSON 字符串
+ * 旧格式: { type: 'card', title: '...', children: [...] }
+ * 新格式: { root: 'card1', elements: { 'card1': { type: 'card', props: { title: '...' }, children: ['text1'] } } }
  */
-function extractJsonFromMarkdown(text: string): any | null {
-  if (typeof text !== 'string') {
-    return null;
+function convertLegacyToSpec(
+  legacySpec: LegacyComponentSpec,
+  elements: Record<string, UIElement> = {},
+  parentKey?: string
+): string {
+  const { type, children, ...restProps } = legacySpec;
+  const key = generateKey();
+
+  // 转换 children(递归)
+  let childKeys: string[] | undefined;
+  if (children && Array.isArray(children) && children.length > 0) {
+    childKeys = children.map((child) =>
+      convertLegacyToSpec(child, elements, key)
+    );
   }
 
-  // 1. 尝试匹配 ```json ... ``` 或 ``` ... ``` 代码块
-  const codeBlockMatch = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
-  if (codeBlockMatch) {
-    try {
-      return JSON.parse(codeBlockMatch[1]);
-    } catch {
-      return null;
-    }
-  }
+  // 创建 UIElement
+  const element: UIElement = {
+    type,
+    props: restProps,
+  };
 
-  // 2. 尝试匹配数组形式的代码块
-  const arrayBlockMatch = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
-  if (arrayBlockMatch) {
-    try {
-      return JSON.parse(arrayBlockMatch[1]);
-    } catch {
-      return null;
-    }
+  if (childKeys && childKeys.length > 0) {
+    element.children = childKeys;
   }
 
-  // 3. 尝试直接解析整个文本
-  try {
-    return JSON.parse(text);
-  } catch {
-    return null;
-  }
+  elements[key] = element;
+  return key;
 }
 
-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;
+/**
+ * 检查是否是官方 Spec 格式
+ */
+function isOfficialSpec(spec: unknown): spec is Spec {
+  if (!spec || typeof spec !== 'object') return false;
+  const s = spec as Record<string, unknown>;
+  return typeof s.root === 'string' && typeof s.elements === 'object';
 }
 
-export default function JsonRenderer({
-  spec,
-  specs,
-  data,
-  toolCalls,
-  error,
-  isLoading,
-  className = '',
-}: JsonRendererProps) {
-  // 获取 ActionContext 中的 emit 函数
-  // 必须在组件顶部调用,不能在条件语句之后
-  const actionContext = useContext(ActionContext);
-
-  // 处理多个 specs
-  const allSpecs = useMemo(() => {
-    const result: AnySpec[] = [];
+/**
+ * 检查是否是旧格式 ComponentSpec
+ */
+function isLegacySpec(spec: unknown): spec is LegacyComponentSpec {
+  if (!spec || typeof spec !== 'object') return false;
+  const s = spec as Record<string, unknown>;
+  return typeof s.type === 'string' && !('root' in s) && !('elements' in s);
+}
 
-    // 添加单个 spec
-    if (spec) {
-      result.push(spec as AnySpec);
-    }
+/**
+ * 将任何格式的 spec 转换为官方 Spec 格式
+ */
+function normalizeSpec(spec: LegacyComponentSpec | Spec | null | undefined): Spec | null {
+  if (!spec) return null;
 
-    // 添加 specs 数组
-    if (specs && specs.length > 0) {
-      result.push(...(specs as AnySpec[]));
-    }
+  // 已经是官方格式
+  if (isOfficialSpec(spec)) {
+    return spec;
+  }
 
-    // 处理原始 data
-    if (data) {
-      let parsedData: any = null;
-
-      // 如果 data 是字符串,尝试从 markdown 中提取 JSON
-      if (typeof data === 'string') {
-        parsedData = extractJsonFromMarkdown(data);
-      } else if (typeof data === 'object' && data !== null && 'type' in data) {
-        // 直接作为 spec
-        parsedData = data;
-      }
-
-      // 如果成功解析了数据
-      if (parsedData) {
-        if (Array.isArray(parsedData)) {
-          // 如果是数组,添加所有元素
-          result.push(...parsedData);
-        } else if (parsedData.type) {
-          // 如果是单个 spec,添加它
-          result.push(parsedData);
-        }
-      }
-    }
+  // 旧格式,需要转换
+  if (isLegacySpec(spec)) {
+    const elements: Record<string, UIElement> = {};
+    const root = convertLegacyToSpec(spec, elements);
 
-    // 处理 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);
-        }
-      }
+    if (Object.keys(elements).length === 0) {
+      return null;
     }
 
-    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>
-    );
+    return { root, elements };
   }
 
-  // 错误状态
-  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>
-    );
-  }
+  return null;
+}
 
-  // 空状态
-  if (allSpecs.length === 0) {
-    return null;
+/**
+ * 将多个 spec 转换为容器 spec
+ */
+function wrapSpecsInContainer(specs: (LegacyComponentSpec | Spec)[]): Spec | null {
+  if (!specs || specs.length === 0) return null;
+
+  // 过滤有效的 specs
+  const validSpecs = specs.filter(Boolean);
+  if (validSpecs.length === 0) return null;
+
+  // 如果只有一个 spec,直接转换
+  if (validSpecs.length === 1) {
+    return normalizeSpec(validSpecs[0]);
   }
 
-  // 渲染组件
-  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) {
-          // 传递 emit 函数给组件(如果存在 ActionContext)
-          const props: any = {
-            ...componentSpec,
-            ...(actionContext && { emit: actionContext.emit }),
-          };
-
-          // 为 Card 和 Stack 组件添加 renderChildren 函数(绑定 emit)
-          if (componentType === 'card' || componentType === 'stack') {
-            const emitFn = actionContext?.emit;
-            props.renderChildren = (children: any) => renderChildren(children, emitFn);
-          }
+  // 多个 specs,包装在 stack 容器中
+  const elements: Record<string, UIElement> = {};
+  keyCounter = 0; // 重置计数器
 
-          return <CustomComponent key={index} {...props} />;
-        }
+  const childKeys = validSpecs.map((spec) => {
+    if (isOfficialSpec(spec)) {
+      // 官方格式,合并 elements
+      Object.assign(elements, spec.elements);
+      return spec.root;
+    }
+    // 旧格式,转换
+    return convertLegacyToSpec(spec as LegacyComponentSpec, elements);
+  });
+
+  // 创建 stack 容器
+  const stackKey = generateKey();
+  elements[stackKey] = {
+    type: 'stack',
+    props: {
+      direction: 'column',
+      spacing: 2,
+    },
+    children: childKeys,
+  };
 
-        // 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>
-  );
+  return { root: stackKey, elements };
 }
 
+// ============ Components ============
+
 /**
- * 用于流式渲染的 Hook
- * 当 spec 通过 SSE 流式传输时使用
+ * 基础 JsonRenderer 组件
+ *
+ * 使用官方 @json-render/react Renderer 渲染组件
  */
-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 }));
+export function JsonRenderer({
+  spec,
+  specs,
+  className,
+  onAction,
+  loading,
+}: JsonRendererProps) {
+  // 转换 spec 格式
+  const finalSpec = useMemo(() => {
+    if (specs && specs.length > 0) {
+      return wrapSpecsInContainer(specs);
     }
-  };
-
-  const completeSpec = () => {
-    if (Object.keys(currentSpec).length > 0) {
-      setSpecs((prev) => [...prev, currentSpec]);
-      setCurrentSpec({});
+    if (spec) {
+      return normalizeSpec(spec);
     }
-    setIsComplete(true);
-  };
+    return null;
+  }, [spec, specs]);
+
+  // 创建 action handlers
+  const handlers = useMemo(() => {
+    const baseHandlers: Record<string, (params?: Record<string, unknown>) => Promise<void> | void> = {
+      sendMessage: async (params?: Record<string, unknown>) => {
+        onAction?.('sendMessage', params);
+      },
+      selectNovel: async (params?: Record<string, unknown>) => {
+        onAction?.('selectNovel', params);
+      },
+      copy: async (params?: Record<string, unknown>) => {
+        onAction?.('copy', params);
+        // 如果有 text 参数,直接复制到剪贴板
+        if (params?.text && typeof params.text === 'string') {
+          try {
+            await navigator.clipboard.writeText(params.text);
+          } catch (e) {
+            console.error('Failed to copy to clipboard:', e);
+          }
+        }
+      },
+    };
+    return baseHandlers;
+  }, [onAction]);
 
-  const reset = () => {
-    setSpecs([]);
-    setCurrentSpec({});
-    setIsComplete(false);
-  };
+  if (!finalSpec) {
+    return null;
+  }
 
-  return {
-    specs,
-    currentSpec,
-    isComplete,
-    addSpecChunk,
-    completeSpec,
-    reset,
-  };
+  return (
+    <div className={className}>
+      <JSONUIProvider
+        registry={registry}
+        handlers={handlers}
+      >
+        <Renderer
+          spec={finalSpec}
+          registry={registry}
+          loading={loading}
+        />
+      </JSONUIProvider>
+    </div>
+  );
 }
 
 /**
- * 从 SSE 事件解析 spec
+ * 流式 JsonRenderer 组件
+ *
+ * 用于 SSE 流式渲染,支持动态更新 specs
  */
-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;
-    }
+export function StreamingJsonRenderer({
+  specs,
+  className,
+  onAction,
+  loading,
+}: StreamingJsonRendererProps) {
+  // 转换 specs 格式
+  const finalSpec = useMemo(() => {
+    if (!specs || specs.length === 0) return null;
+    return wrapSpecsInContainer(specs);
+  }, [specs]);
+
+  // 创建 action handlers
+  const handlers = useMemo(() => {
+    const baseHandlers: Record<string, (params?: Record<string, unknown>) => Promise<void> | void> = {
+      sendMessage: async (params?: Record<string, unknown>) => {
+        onAction?.('sendMessage', params);
+      },
+      selectNovel: async (params?: Record<string, unknown>) => {
+        onAction?.('selectNovel', params);
+      },
+      copy: async (params?: Record<string, unknown>) => {
+        onAction?.('copy', params);
+        if (params?.text && typeof params.text === 'string') {
+          try {
+            await navigator.clipboard.writeText(params.text);
+          } catch (e) {
+            console.error('Failed to copy to clipboard:', e);
+          }
+        }
+      },
+    };
+    return baseHandlers;
+  }, [onAction]);
 
-    return null;
-  } catch {
+  if (!finalSpec) {
     return null;
   }
+
+  return (
+    <div className={className}>
+      <JSONUIProvider
+        registry={registry}
+        handlers={handlers}
+      >
+        <Renderer
+          spec={finalSpec}
+          registry={registry}
+          loading={loading}
+        />
+      </JSONUIProvider>
+    </div>
+  );
 }
 
+// ============ Default Export ============
+
 /**
- * 用于快速渲染单个工具调用结果的辅助组件
+ * 默认导出 - 基础 JsonRenderer
+ *
+ * 支持单个 spec 或 specs 数组
  */
-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);
+export default JsonRenderer;
 
-  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>
-    );
-  }
+// ============ Additional Exports ============
 
-  return <JsonRenderer spec={spec as ComponentSpec} className={className} />;
-}
+// 导出类型
+export type { Spec, StateModel } from '@json-render/react';
+export type { UIElement } from '@json-render/core';
+// 兼容旧类型
+export type ComponentSpec = LegacyComponentSpec;

+ 237 - 0
frontend-v2/lib/catalog/catalog.ts

@@ -0,0 +1,237 @@
+/**
+ * Catalog 定义 - 使用 Zod schemas 定义组件 props
+ */
+import { z } from 'zod';
+import { defineSchema, defineCatalog } from '@json-render/core';
+
+// 使用 defineSchema 定义 schema
+const schema = defineSchema((s) => ({
+  spec: s.object({
+    type: s.ref('catalog.components'),
+    props: s.propsOf('catalog.components'),
+  }),
+  catalog: s.object({
+    components: s.map({
+      props: s.zod(),
+      description: s.string(),
+    }),
+  }),
+}));
+
+// 组件 props Zod schemas
+export const CardProps = z.object({
+  title: z.string().optional(),
+  variant: z.enum(['default', 'outlined', 'elevated']).optional(),
+  className: z.string().optional(),
+});
+
+export const StackProps = z.object({
+  direction: z.enum(['row', 'column']).optional(),
+  spacing: z.number().optional(),
+  align: z.enum(['start', 'center', 'end', 'stretch']).optional(),
+  className: z.string().optional(),
+});
+
+export const HeadingProps = z.object({
+  text: z.string(),
+  level: z.enum(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']).optional(),
+  className: z.string().optional(),
+});
+
+export const TextProps = z.object({
+  text: z.string(),
+  size: z.enum(['xs', 'sm', 'md', 'lg', 'xl']).optional(),
+  color: z.enum(['default', 'muted', 'primary', 'success', 'warning', 'destructive']).optional(),
+  className: z.string().optional(),
+});
+
+export const ButtonProps = z.object({
+  label: z.string(),
+  variant: z.enum(['default', 'primary', 'secondary', 'outline', 'ghost', 'destructive']).optional(),
+  onClick: z.string().optional(),
+  disabled: z.boolean().optional(),
+  className: z.string().optional(),
+});
+
+export const BadgeProps = z.object({
+  text: z.string(),
+  variant: z.enum(['default', 'primary', 'secondary', 'success', 'warning', 'destructive']).optional(),
+  className: z.string().optional(),
+});
+
+export const SeparatorProps = z.object({
+  orientation: z.enum(['horizontal', 'vertical']).optional(),
+  className: z.string().optional(),
+});
+
+export const InputProps = z.object({
+  placeholder: z.string().optional(),
+  type: z.enum(['text', 'password', 'email', 'number']).optional(),
+  disabled: z.boolean().optional(),
+  className: z.string().optional(),
+});
+
+export const TextAreaProps = z.object({
+  placeholder: z.string().optional(),
+  rows: z.number().optional(),
+  disabled: z.boolean().optional(),
+  className: z.string().optional(),
+});
+
+export const DataTableProps = z.object({
+  columns: z.array(z.object({
+    key: z.string(),
+    label: z.string(),
+  })).optional(),
+  data: z.array(z.record(z.string(), z.any())).optional(),
+  className: z.string().optional(),
+});
+
+// MCP 专用组件 props
+export const TranslationResultProps = z.object({
+  original: z.string().optional(),
+  translated: z.string(),
+  termsUsed: z.array(z.string()).optional(),
+  className: z.string().optional(),
+});
+
+export const NovelListProps = z.object({
+  novels: z.array(z.object({
+    id: z.union([z.string(), z.number()]),
+    title: z.string(),
+    author: z.string().optional(),
+    description: z.string().optional(),
+    chapterCount: z.number().optional(),
+    tags: z.array(z.string()).optional(),
+  })),
+  className: z.string().optional(),
+});
+
+export const ChapterReaderProps = z.object({
+  novelTitle: z.string().optional(),
+  chapterTitle: z.string().optional(),
+  content: z.string(),
+  chapterNumber: z.number().optional(),
+  totalChapters: z.number().optional(),
+  className: z.string().optional(),
+});
+
+export const CodeBlockProps = z.object({
+  code: z.string(),
+  language: z.string().optional(),
+  className: z.string().optional(),
+});
+
+export const ToolCallProps = z.object({
+  toolName: z.string(),
+  status: z.enum(['pending', 'running', 'success', 'error']).optional(),
+  result: z.any().optional(),
+  className: z.string().optional(),
+});
+
+export const LoginPanelProps = z.object({
+  serverName: z.string().optional(),
+  onLogin: z.string().optional(),
+  className: z.string().optional(),
+});
+
+export const McpStatusProps = z.object({
+  serverId: z.string(),
+  connected: z.boolean(),
+  className: z.string().optional(),
+});
+
+export const SuggestionButtonsProps = z.object({
+  suggestions: z.array(z.object({
+    label: z.string(),
+    message: z.string().optional(),
+    icon: z.string().optional(),
+  })),
+  className: z.string().optional(),
+});
+
+// 创建 catalog
+const catalog = defineCatalog(schema, {
+  components: {
+    card: {
+      props: CardProps,
+      description: '卡片容器组件,用于包装内容区块',
+    },
+    stack: {
+      props: StackProps,
+      description: '布局容器组件,支持水平和垂直排列',
+    },
+    heading: {
+      props: HeadingProps,
+      description: '标题组件',
+    },
+    text: {
+      props: TextProps,
+      description: '文本组件',
+    },
+    button: {
+      props: ButtonProps,
+      description: '按钮组件',
+    },
+    badge: {
+      props: BadgeProps,
+      description: '徽章组件,用于标签和状态标识',
+    },
+    separator: {
+      props: SeparatorProps,
+      description: '分隔线组件',
+    },
+    input: {
+      props: InputProps,
+      description: '输入框组件',
+    },
+    'text-area': {
+      props: TextAreaProps,
+      description: '多行文本输入组件',
+    },
+    'data-table': {
+      props: DataTableProps,
+      description: '数据表格组件',
+    },
+    'translation-result': {
+      props: TranslationResultProps,
+      description: '翻译结果展示组件',
+    },
+    'novel-list': {
+      props: NovelListProps,
+      description: '小说列表组件',
+    },
+    'chapter-reader': {
+      props: ChapterReaderProps,
+      description: '章节阅读器组件',
+    },
+    'code-block': {
+      props: CodeBlockProps,
+      description: '代码块组件,支持语法高亮',
+    },
+    'tool-call': {
+      props: ToolCallProps,
+      description: 'MCP 工具调用状态组件',
+    },
+    'login-panel': {
+      props: LoginPanelProps,
+      description: 'MCP 登录面板组件',
+    },
+    'mcp-status': {
+      props: McpStatusProps,
+      description: 'MCP 服务器状态组件',
+    },
+    'suggestion-buttons': {
+      props: SuggestionButtonsProps,
+      description: '建议按钮组组件,用于快速选择预定义消息',
+    },
+  },
+});
+
+export default catalog;
+export { schema };
+
+// 兼容旧的导出
+export function getCatalogPrompt(): string {
+  return catalog.prompt();
+}

+ 50 - 418
frontend-v2/lib/component-registry.ts

@@ -1,460 +1,92 @@
 /**
- * 组件注册表 - 动态组件定义
- *
- * 前端维护所有可用的 json-render 组件定义
- * 发送给后端,让 AI 了解可用的 UI 组件
- *
- * ## 架构说明
- *
- * 1. 前端在这里维护所有组件定义(组件类型、描述、Schema)
- * 2. 通过 generateComponentsPrompt() 生成发送给后端的提示词
- * 3. 后端使用这些提示词构建 SYSTEM_PROMPT,无需手动修改后端代码
- * 4. 添加新组件时,只需在这里添加定义即可
+ * 组件注册表 - 直接使用 catalog.prompt() 运行时生成
  */
 
 'use client';
 
-/**
- * 组件配置接口
- */
-interface ComponentConfig {
-  /** 组件类型标识 */
+import { getCatalogPrompt } from './catalog/catalog';
+
+export interface ComponentConfig {
   type: string;
-  /** 组件描述 */
   description: string;
-  /** Schema 字段说明 */
   schema: Record<string, string>;
-  /** 示例 JSON */
-  example?: Record<string, unknown>;
-  /** 使用场景提示 */
-  usageHint?: string;
 }
 
 /**
- * 所有可用的 json-render 组件定义
- *
- * 添加新组件:
- * 1. 在此对象中添加组件定义
- * 2. 无需修改后端代码
+ * 获取组件提示词(同步)
+ * 直接调用 catalog.prompt() 运行时生成
  */
-export const AVAILABLE_COMPONENTS: Record<string, ComponentConfig> = {
-  // ============ MCP 专用组件 ============
+export function generateComponentsPrompt(): string {
+  return getCatalogPrompt();
+}
+
+/**
+ * 异步获取组件提示词(兼容旧代码)
+ */
+export async function getComponentsPromptAsync(): Promise<string> {
+  return getCatalogPrompt();
+}
 
+/**
+ * 预加载(空操作)
+ */
+export function preloadComponentsPrompt(): void {}
+
+/**
+ * 清除缓存(空操作)
+ */
+export function invalidateComponentsPromptCache(): void {}
+
+// 组件定义(向后兼容)
+export const AVAILABLE_COMPONENTS: Record<string, ComponentConfig> = {
   'translation-result': {
     type: 'translation-result',
-    description: '翻译结果卡片,展示译文和使用的术语',
-    schema: {
-      translated: '译文文本',
-      termsUsed: '使用的术语列表(可选)'
-    },
-    usageHint: '调用 translate_text 工具后使用此组件展示结果',
-    example: {
-      type: 'translation-result',
-      translated: '这是一个示例翻译',
-      termsUsed: ['术语1', '术语2']
-    }
+    description: '翻译结果卡片',
+    schema: { translated: '译文', termsUsed: '术语列表' }
   },
-
   'novel-list': {
     type: 'novel-list',
-    description: '小说列表卡片,展示多部小说的摘要信息',
-    schema: {
-      novels: '小说数组,每个小说包含 id, title, author, description, chapterCount, tags'
-    },
-    usageHint: '调用 get_novels 工具后使用此组件展示列表',
-    example: {
-      type: 'novel-list',
-      novels: [
-        {
-          id: '1',
-          title: '示例小说',
-          author: '作者名',
-          description: '这是一本精彩的小说',
-          chapterCount: 100,
-          tags: ['玄幻', '冒险']
-        }
-      ]
-    }
+    description: '小说列表卡片',
+    schema: { novels: '小说数组' }
   },
-
-  'novel-detail': {
-    type: 'novel-detail',
-    description: '小说详情卡片,展示单部小说的完整信息',
-    schema: {
-      novel: '小说对象,包含 id, title, author, category, description, status, chapterCount, wordCount, viewCount, isVip'
-    },
-    usageHint: '调用 get_novel_detail 工具或用户点击小说卡片后使用此组件',
-    example: {
-      type: 'novel-detail',
-      novel: {
-        id: '1',
-        title: '修真世界',
-        author: '方想',
-        category: '玄幻',
-        description: '一个热血少年的修真之路...',
-        status: '已完结',
-        chapterCount: 1200,
-        wordCount: 3500000,
-        viewCount: 500000,
-        isVip: false
-      }
-    }
-  },
-
-  'chapter-reader': {
-    type: 'chapter-reader',
-    description: '章节阅读器,展示小说章节内容',
-    schema: {
-      novelTitle: '小说标题',
-      chapterTitle: '章节标题',
-      content: '章节内容',
-      chapterNumber: '当前章节号(可选)',
-      totalChapters: '总章节数(可选)'
-    },
-    usageHint: '调用 get_chapter 工具后使用此组件展示章节内容',
-    example: {
-      type: 'chapter-reader',
-      novelTitle: '修真世界',
-      chapterTitle: '第一章:开始',
-      content: '章节内容...',
-      chapterNumber: 1,
-      totalChapters: 1200
-    }
-  },
-
-  'suggestion-buttons': {
-    type: 'suggestion-buttons',
-    description: '建议操作按钮组,提供可点击的建议操作',
-    schema: {
-      suggestions: '建议数组,每个建议包含 label(显示文本), message(发送的消息), icon(图标,可选)'
-    },
-    usageHint: '在展示列表、详情后,给出操作建议时使用',
-    example: {
-      type: 'suggestion-buttons',
-      suggestions: [
-        { label: '下一页', icon: '➡️', message: '显示下一页小说' },
-        { label: '返回列表', icon: '🔙', message: '返回小说列表' }
-      ]
-    }
-  },
-
-  'mcp-tool-call': {
-    type: 'mcp-tool-call',
-    description: '工具调用过程展示,显示工具执行的详细状态',
-    schema: {
-      tool: '工具名称',
-      status: '执行状态:pending, running, success, error',
-      args: '工具参数(可选)',
-      result: '执行结果(可选)',
-      error: '错误信息(可选)',
-      timestamp: '时间戳(可选)'
-    },
-    usageHint: '展示工具调用过程时使用',
-    example: {
-      type: 'mcp-tool-call',
-      tool: 'translate_text',
-      status: 'success',
-      args: { text: 'Hello' },
-      result: { translated: '你好' }
-    }
-  },
-
-  'login-panel': {
-    type: 'login-panel',
-    description: '登录面板,提供用户登录界面',
-    schema: {
-      server: '服务器名称',
-      email: '已登录的邮箱(可选)'
-    },
-    usageHint: '需要用户登录 MCP 服务器时使用',
-    example: {
-      type: 'login-panel',
-      server: 'Novel Platform User'
-    }
-  },
-
-  // ============ 基础组件 ============
-
   'card': {
     type: 'card',
-    description: '卡片容器,用于包裹其他内容',
-    schema: {
-      title: '卡片标题(可选)',
-      children: '子组件数组(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要将内容组织成卡片时使用',
-    example: {
-      type: 'card',
-      title: '卡片标题',
-      children: [
-        { type: 'text', content: '内容', variant: 'body' }
-      ]
-    }
+    description: '卡片容器',
+    schema: { title: '标题', children: '子元素' }
   },
-
   'stack': {
     type: 'stack',
-    description: '布局容器,用于排列子元素',
-    schema: {
-      direction: '排列方向:row(横向)或 column(纵向,默认)',
-      spacing: '间距(可选)',
-      align: '对齐方式:start, center, end, stretch(可选)',
-      children: '子组件数组(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要布局多个元素时使用',
-    example: {
-      type: 'stack',
-      direction: 'column',
-      spacing: 2,
-      children: [
-        { type: 'heading', level: 'h3', text: '标题' },
-        { type: 'text', content: '内容' }
-      ]
-    }
+    description: '布局容器',
+    schema: { direction: '方向', spacing: '间距' }
   },
-
   'heading': {
     type: 'heading',
-    description: '标题组件,支持 h1-h6 级别',
-    schema: {
-      level: '标题级别:h1, h2, h3, h4, h5, h6(可选)',
-      text: '标题文本',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要显示标题时使用',
-    example: {
-      type: 'heading',
-      level: 'h2',
-      text: '这是标题'
-    }
+    description: '标题',
+    schema: { text: '文本', level: '级别' }
   },
-
   'text': {
     type: 'text',
-    description: '文本组件,显示正文或次要文本',
-    schema: {
-      content: '文本内容',
-      variant: '文本样式:body(正文), muted(次要文本), code(代码文本)(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要显示段落文本时使用',
-    example: {
-      type: 'text',
-      content: '这是正文内容',
-      variant: 'body'
-    }
+    description: '文本',
+    schema: { text: '内容' }
   },
-
   'button': {
     type: 'button',
-    description: '按钮组件,支持点击交互',
-    schema: {
-      label: '按钮文本',
-      variant: '按钮样式:default, primary, secondary, ghost, danger(可选)',
-      action: '交互事件名,如 sendMessage(可选)',
-      actionPayload: '事件负载数据(可选)',
-      disabled: '是否禁用(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要用户点击执行操作时使用',
-    example: {
-      type: 'button',
-      label: '点击我',
-      variant: 'primary',
-      action: 'sendMessage',
-      actionPayload: { message: '要发送的消息' }
-    }
-  },
-
-  'input': {
-    type: 'input',
-    description: '输入框组件,用于用户输入',
-    schema: {
-      placeholder: '占位符文本(可选)',
-      value: '输入值(可选)',
-      disabled: '是否禁用(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要用户输入文本时使用',
-    example: {
-      type: 'input',
-      placeholder: '请输入...'
-    }
+    description: '按钮',
+    schema: { label: '文本' }
   },
-
   'badge': {
     type: 'badge',
-    description: '徽章标签,显示状态或分类',
-    schema: {
-      text: '标签文本',
-      variant: '标签样式:default, success, warning, error, info(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要显示状态或标签时使用',
-    example: {
-      type: 'badge',
-      text: '已完结',
-      variant: 'success'
-    }
+    description: '徽章',
+    schema: { text: '文本' }
   },
-
   'code-block': {
     type: 'code-block',
-    description: '代码块展示组件',
-    schema: {
-      code: '代码内容',
-      language: '编程语言(可选)',
-      inline: '是否行内显示(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要显示代码时使用',
-    example: {
-      type: 'code-block',
-      code: 'console.log("Hello");',
-      language: 'javascript'
-    }
+    description: '代码块',
+    schema: { code: '代码', language: '语言' }
   },
-
-  'data-table': {
-    type: 'data-table',
-    description: '数据表格,展示结构化数据',
-    schema: {
-      columns: '列定义数组,每列包含 key, label, sortable',
-      rows: '行数据数组,每行是一个对象',
-      sortable: '是否支持排序(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要展示表格数据时使用',
-    example: {
-      type: 'data-table',
-      columns: [
-        { key: 'name', label: '名称' },
-        { key: 'value', label: '值' }
-      ],
-      rows: [
-        { name: '项目1', value: '值1' },
-        { name: '项目2', value: '值2' }
-      ]
-    }
-  },
-
-  'separator': {
-    type: 'separator',
-    description: '分隔线,用于视觉分隔',
-    schema: {
-      orientation: '方向:horizontal(水平,默认)或 vertical(垂直)(可选)',
-      className: '自定义样式类名(可选)'
-    },
-    usageHint: '需要在内容间添加分隔时使用',
-    example: {
-      type: 'separator',
-      orientation: 'horizontal'
-    }
+  'tool-call': {
+    type: 'tool-call',
+    description: '工具调用状态',
+    schema: { toolName: '名称', status: '状态' }
   }
 };
-
-/**
- * 组件类型
- */
-export type ComponentType = keyof typeof AVAILABLE_COMPONENTS;
-
-/**
- * 生成组件使用说明文本
- *
- * 此函数将所有组件定义转换为发送给后端的提示词格式
- * 后端使用这些提示词构建 Claude 的 SYSTEM_PROMPT
- *
- * @returns 组件说明文本(Markdown 格式)
- */
-export function generateComponentsPrompt(): string {
-  const components = Object.entries(AVAILABLE_COMPONENTS);
-
-  // 分组:MCP 专用组件 和 基础组件
-  const mcpComponents = components.filter(([key]) => {
-    const config = AVAILABLE_COMPONENTS[key];
-    return [
-      'translation-result', 'novel-list', 'novel-detail', 'chapter-reader',
-      'suggestion-buttons', 'mcp-tool-call', 'login-panel'
-    ].includes(config.type);
-  });
-
-  const baseComponents = components.filter(([key]) => {
-    const config = AVAILABLE_COMPONENTS[key];
-    return ![
-      'translation-result', 'novel-list', 'novel-detail', 'chapter-reader',
-      'suggestion-buttons', 'mcp-tool-call', 'login-panel'
-    ].includes(config.type);
-  });
-
-  let prompt = '## 可用的 json-render 组件\n\n';
-  prompt += '你可以使用以下组件来展示结构化数据。组件是 JSON 对象,放在回复的 ```json 代码块中。\n\n';
-
-  // MCP 专用组件
-  if (mcpComponents.length > 0) {
-    prompt += '### MCP 专用组件\n\n';
-    prompt += '这些组件用于展示 MCP 工具调用的结果:\n\n';
-
-    mcpComponents.forEach(([key, config]) => {
-      prompt += `#### ${config.type}\n`;
-      prompt += `${config.description}\n`;
-      if (config.usageHint) {
-        prompt += `> ${config.usageHint}\n`;
-      }
-      prompt += '\n**Schema:**\n';
-      Object.entries(config.schema).forEach(([fieldName, description]) => {
-        prompt += `- \`${fieldName}\`: ${description}\n`;
-      });
-      prompt += '\n**示例:**\n';
-      prompt += '```json\n';
-      prompt += JSON.stringify(config.example || { type: key }, null, 2);
-      prompt += '\n```\n\n';
-    });
-  }
-
-  // 基础组件
-  if (baseComponents.length > 0) {
-    prompt += '### 基础组件\n\n';
-    prompt += '这些组件用于构建通用 UI:\n\n';
-
-    baseComponents.forEach(([key, config]) => {
-      prompt += `#### ${config.type}\n`;
-      prompt += `${config.description}\n`;
-      if (config.usageHint) {
-        prompt += `> ${config.usageHint}\n`;
-      }
-      prompt += '\n**Schema:**\n';
-      Object.entries(config.schema).forEach(([fieldName, description]) => {
-        prompt += `- \`${fieldName}\`: ${description}\n`;
-      });
-      prompt += '\n**示例:**\n';
-      prompt += '```json\n';
-      prompt += JSON.stringify(config.example || { type: key }, null, 2);
-      prompt += '\n```\n\n';
-    });
-  }
-
-  return prompt;
-}
-
-/**
- * 获取组件定义
- */
-export function getComponentConfig(type: string): ComponentConfig | undefined {
-  return AVAILABLE_COMPONENTS[type];
-}
-
-/**
- * 获取所有组件类型列表
- */
-export function getComponentTypes(): string[] {
-  return Object.keys(AVAILABLE_COMPONENTS);
-}
-
-/**
- * 检查组件类型是否有效
- */
-export function isValidComponentType(type: string): boolean {
-  return type in AVAILABLE_COMPONENTS;
-}

+ 38 - 11
frontend-v2/lib/hooks.ts

@@ -4,6 +4,8 @@
 'use client';
 
 import { useState, useCallback, useRef, useEffect } from 'react';
+import { createMixedStreamParser, createSpecStreamCompiler, type SpecStreamLine } from '@json-render/core';
+import type { Spec } from '@json-render/core';
 import { apiClient, type ChatMessage, type UserInfo, type UserRole } from './api-client';
 
 // SSE 事件类型
@@ -33,6 +35,11 @@ export function useChat() {
   const abortRef = useRef<(() => void) | null>(null);
   const toolCallsRef = useRef<Array<{ tool: string; result: unknown }>>([]);
 
+  // 流式编译器相关 refs
+  const streamCompilerRef = useRef<ReturnType<typeof createSpecStreamCompiler> | null>(null);
+  const mixedParserRef = useRef<ReturnType<typeof createMixedStreamParser> | null>(null);
+  const textBufferRef = useRef<string>('');
+
   // 保持 ref 同步
   useEffect(() => {
     toolCallsRef.current = toolCalls;
@@ -188,6 +195,30 @@ export function useChat() {
     setSpecs([]);
     setError(null);
 
+    // 初始化流式编译器和混合解析器
+    streamCompilerRef.current = createSpecStreamCompiler<Spec>();
+    textBufferRef.current = '';
+
+    mixedParserRef.current = createMixedStreamParser({
+      onPatch: (patch: SpecStreamLine) => {
+        console.log('[MixedParser] Received patch:', patch);
+        // 直接使用 compiler 处理 patch
+        if (streamCompilerRef.current) {
+          const { result } = streamCompilerRef.current.push(JSON.stringify(patch) + '\n');
+          console.log('[MixedParser] Compiler result:', result);
+          // 更新 specs 状态
+          if (result && typeof result === 'object') {
+            setSpecs([result]);
+          }
+        }
+      },
+      onText: (text: string) => {
+        console.log('[MixedParser] Received text:', text.substring(0, 50));
+        textBufferRef.current += text;
+        setResponse(textBufferRef.current);
+      }
+    });
+
     try {
       abortRef.current = await apiClient.chatStreamFetch(
         message,
@@ -229,17 +260,9 @@ export function useChat() {
     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;
-          });
+        if (tokenData.text && mixedParserRef.current) {
+          // 使用混合解析器处理流式数据
+          mixedParserRef.current.push(tokenData.text);
         }
         break;
       case 'tool_call':
@@ -296,6 +319,10 @@ export function useChat() {
       case 'complete':
         const completeData = event as { response?: string; tool_calls?: unknown };
         console.log('[Complete]', completeData);
+        // 刷新混合解析器缓冲区
+        if (mixedParserRef.current) {
+          mixedParserRef.current.flush();
+        }
         console.log('[Complete] Response field:', completeData.response?.substring(0, 100));
         // 使用函数式更新来避免闭包陷阱
         setResponse((prev) => {

+ 474 - 0
frontend-v2/lib/registry.tsx

@@ -0,0 +1,474 @@
+/**
+ * json-render 注册表
+ *
+ * 使用官方 defineRegistry API 创建组件注册表
+ */
+
+'use client';
+
+import { useState } from 'react';
+import { defineRegistry, type BaseComponentProps } from '@json-render/react';
+import { useActionEmit } from '@/lib/action-context';
+import { z } from 'zod';
+import catalog, {
+  CardProps,
+  StackProps,
+  HeadingProps,
+  TextProps,
+  ButtonProps,
+  BadgeProps,
+  SeparatorProps,
+  InputProps,
+  TextAreaProps,
+  DataTableProps,
+  TranslationResultProps,
+  NovelListProps,
+  ChapterReaderProps,
+  CodeBlockProps,
+  ToolCallProps,
+  LoginPanelProps,
+  McpStatusProps,
+  SuggestionButtonsProps,
+} from './catalog/catalog';
+
+// ============ 基础组件 ============
+
+const Card = ({ props, children }: BaseComponentProps<z.infer<typeof CardProps>>) => (
+  <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 shadow-sm p-4 ${props.className || ''}`}>
+    {props.title && <h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">{props.title}</h3>}
+    {children}
+  </div>
+);
+
+const Stack = ({ props, children }: BaseComponentProps<z.infer<typeof StackProps>>) => {
+  const directionClass = props.direction === 'row' ? 'flex-row' : 'flex-col';
+  const spacingClass = (props.spacing ?? 2) > 0 ? `gap-${props.spacing ?? 2}` : '';
+  const alignClass = props.align === 'center' ? 'items-center'
+    : props.align === 'end' ? 'items-end'
+    : props.align === 'stretch' ? 'items-stretch'
+    : 'items-start';
+
+  return (
+    <div className={`flex ${directionClass} ${spacingClass} ${alignClass} ${props.className || ''}`}>
+      {children}
+    </div>
+  );
+};
+
+const Heading = ({ props }: BaseComponentProps<z.infer<typeof HeadingProps>>) => {
+  const level = props.level || 'h2';
+  const Tag = level as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+  const sizeClasses: Record<string, string> = {
+    h1: 'text-3xl font-bold',
+    h2: 'text-2xl font-semibold',
+    h3: 'text-xl font-semibold',
+    h4: 'text-lg font-medium',
+    h5: 'text-base font-medium',
+    h6: 'text-sm font-medium',
+  };
+  const sizeClass = sizeClasses[level] || sizeClasses.h2;
+
+  return <Tag className={`${sizeClass} text-gray-900 dark:text-white ${props.className || ''}`}>{props.text}</Tag>;
+};
+
+const Text = ({ props }: BaseComponentProps<z.infer<typeof TextProps>>) => {
+  const variantClasses: Record<string, string> = {
+    default: 'text-gray-700 dark:text-gray-300',
+    muted: 'text-gray-500 dark:text-gray-400',
+    primary: 'text-blue-600 dark:text-blue-400',
+    success: 'text-green-600 dark:text-green-400',
+    warning: 'text-yellow-600 dark:text-yellow-400',
+    destructive: 'text-red-600 dark:text-red-400',
+  };
+  const sizeClasses: Record<string, string> = {
+    xs: 'text-xs',
+    sm: 'text-sm',
+    md: 'text-base',
+    lg: 'text-lg',
+    xl: 'text-xl',
+  };
+  const color = props.color || 'default';
+  const size = props.size || 'md';
+
+  return <p className={`${variantClasses[color]} ${sizeClasses[size]} ${props.className || ''}`}>{props.text}</p>;
+};
+
+const Button = ({ props, emit }: BaseComponentProps<z.infer<typeof ButtonProps>>) => {
+  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',
+    secondary: 'bg-purple-600 hover:bg-purple-700 text-white',
+    outline: 'border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
+    ghost: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
+    destructive: 'bg-red-600 hover:bg-red-700 text-white',
+  };
+  const variant = props.variant || 'default';
+
+  const handleClick = () => {
+    if (props.disabled) return;
+    if (props.onClick) {
+      emit(props.onClick);
+    }
+  };
+
+  return (
+    <button
+      onClick={handleClick}
+      disabled={props.disabled}
+      className={`${variantClasses[variant]} px-4 py-2 rounded-md font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${props.className || ''}`}
+    >
+      {props.label}
+    </button>
+  );
+};
+
+const Badge = ({ props }: BaseComponentProps<z.infer<typeof BadgeProps>>) => {
+  const variantClasses: Record<string, string> = {
+    default: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
+    primary: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200',
+    secondary: 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200',
+    success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200',
+    warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200',
+    destructive: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200',
+  };
+  const variant = props.variant || 'default';
+
+  return (
+    <span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${variantClasses[variant]} ${props.className || ''}`}>
+      {props.text}
+    </span>
+  );
+};
+
+const Separator = ({ props }: BaseComponentProps<z.infer<typeof SeparatorProps>>) => {
+  const orientation = props.orientation || 'horizontal';
+  const orientationClass = orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full';
+  return <div className={`bg-gray-200 dark:bg-gray-700 ${orientationClass} ${props.className || ''}`} />;
+};
+
+const Input = ({ props }: BaseComponentProps<z.infer<typeof InputProps>>) => (
+  <input
+    type={props.type || 'text'}
+    placeholder={props.placeholder}
+    disabled={props.disabled}
+    className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 ${props.className || ''}`}
+  />
+);
+
+const TextArea = ({ props }: BaseComponentProps<z.infer<typeof TextAreaProps>>) => (
+  <textarea
+    placeholder={props.placeholder}
+    rows={props.rows || 3}
+    disabled={props.disabled}
+    className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 resize-none ${props.className || ''}`}
+  />
+);
+
+const DataTable = ({ props }: BaseComponentProps<z.infer<typeof DataTableProps>>) => (
+  <div className={`overflow-x-auto ${props.className || ''}`}>
+    <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
+      <thead className="bg-gray-50 dark:bg-gray-800">
+        <tr>
+          {props.columns?.map((col) => (
+            <th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
+              {col.label}
+            </th>
+          ))}
+        </tr>
+      </thead>
+      <tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
+        {props.data?.map((row, idx) => (
+          <tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800">
+            {props.columns?.map((col) => (
+              <td key={col.key} className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
+                {String(row[col.key] ?? '')}
+              </td>
+            ))}
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  </div>
+);
+
+// ============ MCP 专用组件 ============
+
+const TranslationResult = ({ props }: BaseComponentProps<z.infer<typeof TranslationResultProps>>) => {
+  const [copied, setCopied] = useState(false);
+
+  const handleCopy = () => {
+    if (props.translated) {
+      navigator.clipboard.writeText(props.translated);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    }
+  };
+
+  return (
+    <div className={`bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden ${props.className || ''}`}>
+      <div className="bg-gradient-to-r from-blue-500 to-purple-500 px-4 py-3 flex items-center gap-2">
+        <span className="text-xl">🌐</span>
+        <h4 className="font-semibold text-white">翻译结果</h4>
+      </div>
+
+      <div className="p-4">
+        {props.original && (
+          <div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 mb-3">
+            <span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">原文</span>
+            <p className="text-gray-600 dark:text-gray-400 leading-relaxed mt-1">{props.original}</p>
+          </div>
+        )}
+
+        <div className="bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-lg p-3 border border-blue-100 dark:border-blue-800/50 mb-3">
+          <span className="text-xs font-medium text-blue-600 dark:text-blue-400 uppercase tracking-wide">译文</span>
+          <p className="text-gray-900 dark:text-gray-100 font-medium leading-relaxed mt-1">{props.translated}</p>
+        </div>
+
+        {props.termsUsed && props.termsUsed.length > 0 && (
+          <div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
+            <span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">使用术语</span>
+            <div className="flex flex-wrap gap-2 mt-1">
+              {props.termsUsed.map((term, idx) => (
+                <span key={idx} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200">
+                  {term}
+                </span>
+              ))}
+            </div>
+          </div>
+        )}
+      </div>
+
+      <div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/30 border-t dark:border-gray-700 flex gap-2">
+        <button
+          onClick={handleCopy}
+          className="flex-1 flex items-center justify-center gap-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
+        >
+          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
+          </svg>
+          <span>{copied ? '已复制!' : '复制译文'}</span>
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const NovelList = ({ props, emit }: BaseComponentProps<z.infer<typeof NovelListProps>>) => (
+  <div className={`space-y-3 ${props.className || ''}`}>
+    {props.novels?.map((novel) => (
+      <div
+        key={String(novel.id)}
+        onClick={() => emit('selectNovel')}
+        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>}
+        <div className="flex items-center gap-2">
+          {novel.chapterCount && (
+            <span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">
+              {novel.chapterCount} chapters
+            </span>
+          )}
+          {novel.tags?.map((tag) => (
+            <span key={tag} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
+              {tag}
+            </span>
+          ))}
+        </div>
+      </div>
+    ))}
+  </div>
+);
+
+const ChapterReader = ({ props }: BaseComponentProps<z.infer<typeof ChapterReaderProps>>) => (
+  <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-6 ${props.className || ''}`}>
+    <div className="border-b dark:border-gray-700 pb-4 mb-4">
+      {props.novelTitle && <p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{props.novelTitle}</p>}
+      {props.chapterTitle && <h2 className="text-2xl font-bold text-gray-900 dark:text-white">{props.chapterTitle}</h2>}
+      {props.chapterNumber && props.totalChapters && (
+        <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
+          Chapter {props.chapterNumber} of {props.totalChapters}
+        </p>
+      )}
+    </div>
+    <div className="prose dark:prose-invert max-w-none">
+      <p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">{props.content}</p>
+    </div>
+  </div>
+);
+
+const CodeBlock = ({ props }: BaseComponentProps<z.infer<typeof CodeBlockProps>>) => {
+  const [copied, setCopied] = useState(false);
+
+  const handleCopy = () => {
+    if (props.code) {
+      navigator.clipboard.writeText(props.code);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    }
+  };
+
+  return (
+    <div className={`bg-gray-900 dark:bg-gray-950 rounded-lg overflow-hidden ${props.className || ''}`}>
+      <div className="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-gray-900">
+        <span className="text-xs text-gray-400 uppercase">{props.language || 'text'}</span>
+        <button
+          onClick={handleCopy}
+          className="text-xs text-gray-400 hover:text-white transition-colors"
+        >
+          {copied ? '已复制!' : 'Copy'}
+        </button>
+      </div>
+      <pre className="p-4 overflow-x-auto">
+        <code className="text-sm text-gray-100 font-mono">{props.code}</code>
+      </pre>
+    </div>
+  );
+};
+
+const ToolCall = ({ props }: BaseComponentProps<z.infer<typeof ToolCallProps>>) => {
+  const statusConfig: Record<string, { icon: string; color: string; bg: string }> = {
+    pending: { icon: '⏳', color: 'text-yellow-600', bg: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-700' },
+    running: { icon: '🔄', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' },
+    success: { icon: '✅', color: 'text-green-600', bg: 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700' },
+    error: { icon: '❌', color: 'text-red-600', bg: 'bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-700' },
+  };
+  const status = props.status || 'success';
+  const config = statusConfig[status];
+
+  return (
+    <div className={`border-l-4 p-3 rounded-r ${config.bg}`}>
+      <div className="flex items-center gap-2 mb-2">
+        <span className="text-lg">{config.icon}</span>
+        <span className={`font-mono text-sm font-medium ${config.color}`}>{props.toolName}</span>
+      </div>
+      {props.result && status === 'success' && (
+        <div className="mt-2">
+          <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Result:</p>
+          <pre className="text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto">
+            {typeof props.result === 'string' ? props.result : JSON.stringify(props.result, null, 2)}
+          </pre>
+        </div>
+      )}
+      {status === 'error' && (
+        <div className="mt-2">
+          <p className="text-xs text-red-600 dark:text-red-400">Error occurred</p>
+        </div>
+      )}
+    </div>
+  );
+};
+
+const LoginPanel = ({ props, emit }: BaseComponentProps<z.infer<typeof LoginPanelProps>>) => {
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+
+  const handleLogin = () => {
+    if (props.onLogin) {
+      emit(props.onLogin);
+    }
+  };
+
+  return (
+    <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-6 ${props.className || ''}`}>
+      <div className="text-center mb-6">
+        <div className="text-4xl mb-2">🔐</div>
+        <h3 className="text-xl font-semibold text-gray-900 dark:text-white">{props.serverName || 'MCP Server'}</h3>
+        <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to continue</p>
+      </div>
+      <div className="space-y-4">
+        <div>
+          <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
+          <input
+            type="email"
+            placeholder="your@email.com"
+            value={email}
+            onChange={(e) => setEmail(e.target.value)}
+            className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500"
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
+          <input
+            type="password"
+            placeholder="••••••••"
+            value={password}
+            onChange={(e) => setPassword(e.target.value)}
+            className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500"
+          />
+        </div>
+        <button
+          onClick={handleLogin}
+          className="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium transition-colors"
+        >
+          Sign In
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const McpStatus = ({ props }: BaseComponentProps<z.infer<typeof McpStatusProps>>) => (
+  <div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${props.connected ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'} ${props.className || ''}`}>
+    <span className={`w-2 h-2 rounded-full ${props.connected ? 'bg-green-500' : 'bg-red-500'}`} />
+    <span className="text-sm font-medium">
+      {props.serverId}: {props.connected ? 'Connected' : 'Disconnected'}
+    </span>
+  </div>
+);
+
+const SuggestionButtons = ({ props }: BaseComponentProps<z.infer<typeof SuggestionButtonsProps>>) => {
+  const { emit } = useActionEmit();
+
+  const handleSelect = (suggestion: { label: string; message?: string }) => {
+    const message = suggestion.message || suggestion.label;
+    emit('sendMessage', { message });
+  };
+
+  return (
+    <div className="flex flex-wrap gap-2">
+      {props.suggestions?.map((s, i) => (
+        <button
+          key={i}
+          onClick={() => handleSelect(s)}
+          className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full text-sm transition-colors flex items-center gap-1.5"
+        >
+          {s.icon && <span>{s.icon}</span>}
+          <span>{s.label}</span>
+        </button>
+      ))}
+    </div>
+  );
+};
+
+// ============ 创建并导出注册表 ============
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    // 基础组件
+    card: Card,
+    stack: Stack,
+    heading: Heading,
+    text: Text,
+    button: Button,
+    badge: Badge,
+    separator: Separator,
+    input: Input,
+    'text-area': TextArea,
+    'data-table': DataTable,
+
+    // MCP 专用组件
+    'translation-result': TranslationResult,
+    'novel-list': NovelList,
+    'chapter-reader': ChapterReader,
+    'code-block': CodeBlock,
+    'tool-call': ToolCall,
+    'login-panel': LoginPanel,
+    'mcp-status': McpStatus,
+    'suggestion-buttons': SuggestionButtons,
+  },
+});
+
+export { registry };
+export type { BaseComponentProps } from '@json-render/react';