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:
91
frontend-v2/app/api/components-prompt/route.ts
Normal file
91
frontend-v2/app/api/components-prompt/route.ts
Normal 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": []
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 spec,Renderer 会处理
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
237
frontend-v2/lib/catalog/catalog.ts
Normal file
237
frontend-v2/lib/catalog/catalog.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
frontend-v2/lib/registry.tsx
Normal file
474
frontend-v2/lib/registry.tsx
Normal 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';
|
||||
Reference in New Issue
Block a user