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>
This commit is contained in:
Claude AI
2026-03-20 03:07:36 +00:00
parent a2da92cecc
commit 26c8690374
8 changed files with 1350 additions and 879 deletions

View File

@@ -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": []
}
\`\`\`
`;
}

View File

@@ -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">

View File

@@ -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;
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++;
}
const obj = data as Record<string, unknown>;
const type = obj.type;
if (typeof type !== 'string') {
return false;
}
// 检查 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$/, '');
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;
}
}
// 内联代码直接渲染
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>
);
/**
* 从编译后的 spec 中提取可渲染的元素
* 对于 flat 格式 {root, elements},返回 root 指向的元素
*/
function extractRenderableElements(spec: Spec): Spec[] {
const elements: Spec[] = [];
if (!spec || typeof spec !== 'object') return elements;
// 如果 spec 有 root 和 elements 字段flat 格式)
// 需要渲染整个 spec 对象,让 Renderer 根据 root 找到对应元素
if (spec.root && spec.elements && typeof spec.elements === 'object') {
// 返回完整的 flat specRenderer 会处理
elements.push(spec);
return elements;
}
// 提取语言标识
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');
function WrappedSpec() {
const actionContext = useContext(ActionContext);
return <JsonRenderer spec={parsed as ComponentSpec} />;
}
return <WrappedSpec />;
};
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[]} />;
}
return <WrappedSpecs />;
};
return <SpecsComponent />;
}
}
} catch {
// JSON 解析失败,继续渲染为普通代码块
}
}
// 渲染为普通代码块
// 如果 spec 本身就是一个组件
if ('type' in spec && spec.type) {
elements.push(spec);
}
return elements;
}
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]);
// 如果有编译出的 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 渲染
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>
)}
{/* Markdown 渲染区域(仅 assistant 消息) */}
<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>

View File

@@ -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';
// 避免直接导入 json-render 类型,使用 any 来处理类型兼容性
type AnySpec = Record<string, any>;
// ============ Types ============
/**
* 从 markdown 代码块中提取 JSON
*
* 支持以下格式:
* - ```json {...} ```
* - ``` {...} ```
* - 直接的 JSON 字符串
* 旧格式组件 spec 类型
* 旧格式: { type: 'card', title: '...', children: [...] }
*/
function extractJsonFromMarkdown(text: string): any | null {
if (typeof text !== 'string') {
return null;
}
// 1. 尝试匹配 ```json ... ``` 或 ``` ... ``` 代码块
const codeBlockMatch = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
if (codeBlockMatch) {
try {
return JSON.parse(codeBlockMatch[1]);
} catch {
return null;
}
}
// 2. 尝试匹配数组形式的代码块
const arrayBlockMatch = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
if (arrayBlockMatch) {
try {
return JSON.parse(arrayBlockMatch[1]);
} catch {
return null;
}
}
// 3. 尝试直接解析整个文本
try {
return JSON.parse(text);
} catch {
return null;
}
export interface LegacyComponentSpec {
type: string;
children?: LegacyComponentSpec[];
[key: string]: unknown;
}
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 类名
/**
* 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;
}
export default function JsonRenderer({
/**
* StreamingJsonRenderer Props - 用于 SSE 流式渲染
*/
export interface StreamingJsonRendererProps extends JsonRendererProps {
/** 流式数据 */
specs?: LegacyComponentSpec[];
}
// ============ Helper Functions ============
/**
* 生成唯一 key
*/
let keyCounter = 0;
function generateKey(): string {
return `el_${++keyCounter}`;
}
/**
* 将旧的嵌套格式 ComponentSpec 转换为扁平化的官方 Spec 格式
*
* 旧格式: { type: 'card', title: '...', children: [...] }
* 新格式: { root: 'card1', elements: { 'card1': { type: 'card', props: { title: '...' }, children: ['text1'] } } }
*/
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)
);
}
// 创建 UIElement
const element: UIElement = {
type,
props: restProps,
};
if (childKeys && childKeys.length > 0) {
element.children = childKeys;
}
elements[key] = element;
return key;
}
/**
* 检查是否是官方 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';
}
/**
* 检查是否是旧格式 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 转换为官方 Spec 格式
*/
function normalizeSpec(spec: LegacyComponentSpec | Spec | null | undefined): Spec | null {
if (!spec) return null;
// 已经是官方格式
if (isOfficialSpec(spec)) {
return spec;
}
// 旧格式,需要转换
if (isLegacySpec(spec)) {
const elements: Record<string, UIElement> = {};
const root = convertLegacyToSpec(spec, elements);
if (Object.keys(elements).length === 0) {
return null;
}
return { root, elements };
}
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]);
}
// 多个 specs包装在 stack 容器中
const elements: Record<string, UIElement> = {};
keyCounter = 0; // 重置计数器
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,
};
return { root: stackKey, elements };
}
// ============ Components ============
/**
* 基础 JsonRenderer 组件
*
* 使用官方 @json-render/react Renderer 渲染组件
*/
export function JsonRenderer({
spec,
specs,
data,
toolCalls,
error,
isLoading,
className = '',
className,
onAction,
loading,
}: JsonRendererProps) {
// 获取 ActionContext 中的 emit 函数
// 必须在组件顶部调用,不能在条件语句之后
const actionContext = useContext(ActionContext);
// 处理多个 specs
const allSpecs = useMemo(() => {
const result: AnySpec[] = [];
// 添加单个 spec
if (spec) {
result.push(spec as AnySpec);
}
// 添加 specs 数组
// 转换 spec 格式
const finalSpec = useMemo(() => {
if (specs && specs.length > 0) {
result.push(...(specs as AnySpec[]));
return wrapSpecsInContainer(specs);
}
if (spec) {
return normalizeSpec(spec);
}
return null;
}, [spec, specs]);
// 处理原始 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);
// 创建 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]);
// 处理 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) {
if (!finalSpec) {
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) {
// 传递 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);
}
return <CustomComponent key={index} {...props} />;
}
// 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 className={className}>
<JSONUIProvider
registry={registry}
handlers={handlers}
>
<Renderer
spec={finalSpec}
registry={registry}
loading={loading}
/>
</JSONUIProvider>
</div>
);
}
/**
* 用于流式渲染的 Hook
* 当 spec 通过 SSE 流式传输时使用
* 流式 JsonRenderer 组件
*
* 用于 SSE 流式渲染,支持动态更新 specs
*/
export function useStreamingJsonRenderer() {
const [specs, setSpecs] = useState<AnySpec[]>([]);
const [currentSpec, setCurrentSpec] = useState<Partial<AnySpec>>({});
const [isComplete, setIsComplete] = useState(false);
export function StreamingJsonRenderer({
specs,
className,
onAction,
loading,
}: StreamingJsonRendererProps) {
// 转换 specs 格式
const finalSpec = useMemo(() => {
if (!specs || specs.length === 0) return null;
return wrapSpecsInContainer(specs);
}, [specs]);
const addSpecChunk = (chunk: unknown) => {
if (typeof chunk === 'object' && chunk !== null) {
setCurrentSpec((prev) => ({ ...prev, ...chunk }));
}
};
// 创建 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]);
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 {
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 default JsonRenderer;
export function ToolCallResult({ tool, result, className }: ToolCallResultProps) {
const { specFromToolCall } = require('@/lib/json-render-catalog');
const spec = specFromToolCall(tool, result);
// ============ Additional Exports ============
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} />;
}
// 导出类型
export type { Spec, StateModel } from '@json-render/react';
export type { UIElement } from '@json-render/core';
// 兼容旧类型
export type ComponentSpec = LegacyComponentSpec;

View File

@@ -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();
}

View File

@@ -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. 无需修改后端代码
*/
export const AVAILABLE_COMPONENTS: Record<string, ComponentConfig> = {
// ============ MCP 专用组件 ============
'translation-result': {
type: 'translation-result',
description: '翻译结果卡片,展示译文和使用的术语',
schema: {
translated: '译文文本',
termsUsed: '使用的术语列表(可选)'
},
usageHint: '调用 translate_text 工具后使用此组件展示结果',
example: {
type: 'translation-result',
translated: '这是一个示例翻译',
termsUsed: ['术语1', '术语2']
}
},
'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: ['玄幻', '冒险']
}
]
}
},
'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' }
]
}
},
'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: '内容' }
]
}
},
'heading': {
type: 'heading',
description: '标题组件,支持 h1-h6 级别',
schema: {
level: '标题级别h1, h2, h3, h4, h5, h6可选',
text: '标题文本',
className: '自定义样式类名(可选)'
},
usageHint: '需要显示标题时使用',
example: {
type: 'heading',
level: 'h2',
text: '这是标题'
}
},
'text': {
type: 'text',
description: '文本组件,显示正文或次要文本',
schema: {
content: '文本内容',
variant: '文本样式body正文, muted次要文本, code代码文本可选',
className: '自定义样式类名(可选)'
},
usageHint: '需要显示段落文本时使用',
example: {
type: 'text',
content: '这是正文内容',
variant: 'body'
}
},
'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: '请输入...'
}
},
'badge': {
type: 'badge',
description: '徽章标签,显示状态或分类',
schema: {
text: '标签文本',
variant: '标签样式default, success, warning, error, info可选',
className: '自定义样式类名(可选)'
},
usageHint: '需要显示状态或标签时使用',
example: {
type: 'badge',
text: '已完结',
variant: 'success'
}
},
'code-block': {
type: 'code-block',
description: '代码块展示组件',
schema: {
code: '代码内容',
language: '编程语言(可选)',
inline: '是否行内显示(可选)',
className: '自定义样式类名(可选)'
},
usageHint: '需要显示代码时使用',
example: {
type: 'code-block',
code: 'console.log("Hello");',
language: 'javascript'
}
},
'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'
}
}
};
/**
* 组件类型
*/
export type ComponentType = keyof typeof AVAILABLE_COMPONENTS;
/**
* 生成组件使用说明文本
*
* 此函数将所有组件定义转换为发送给后端的提示词格式
* 后端使用这些提示词构建 Claude 的 SYSTEM_PROMPT
*
* @returns 组件说明文本Markdown 格式)
* 获取组件提示词(同步)
* 直接调用 catalog.prompt() 运行时生成
*/
export function generateComponentsPrompt(): string {
const components = Object.entries(AVAILABLE_COMPONENTS);
return getCatalogPrompt();
}
// 分组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);
});
/**
* 异步获取组件提示词(兼容旧代码)
*/
export async function getComponentsPromptAsync(): Promise<string> {
return getCatalogPrompt();
}
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);
});
/**
* 预加载(空操作)
*/
export function preloadComponentsPrompt(): void {}
let prompt = '## 可用的 json-render 组件\n\n';
prompt += '你可以使用以下组件来展示结构化数据。组件是 JSON 对象,放在回复的 ```json 代码块中。\n\n';
/**
* 清除缓存(空操作)
*/
export function invalidateComponentsPromptCache(): void {}
// 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';
});
// 组件定义(向后兼容)
export const AVAILABLE_COMPONENTS: Record<string, ComponentConfig> = {
'translation-result': {
type: 'translation-result',
description: '翻译结果卡片',
schema: { translated: '译文', termsUsed: '术语列表' }
},
'novel-list': {
type: 'novel-list',
description: '小说列表卡片',
schema: { novels: '小说数组' }
},
'card': {
type: 'card',
description: '卡片容器',
schema: { title: '标题', children: '子元素' }
},
'stack': {
type: 'stack',
description: '布局容器',
schema: { direction: '方向', spacing: '间距' }
},
'heading': {
type: 'heading',
description: '标题',
schema: { text: '文本', level: '级别' }
},
'text': {
type: 'text',
description: '文本',
schema: { text: '内容' }
},
'button': {
type: 'button',
description: '按钮',
schema: { label: '文本' }
},
'badge': {
type: 'badge',
description: '徽章',
schema: { text: '文本' }
},
'code-block': {
type: 'code-block',
description: '代码块',
schema: { code: '代码', language: '语言' }
},
'tool-call': {
type: 'tool-call',
description: '工具调用状态',
schema: { toolName: '名称', status: '状态' }
}
// 基础组件
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;
}
};

View File

@@ -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) => {

View File

@@ -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';