diff --git a/frontend-v2/app/api/components-prompt/route.ts b/frontend-v2/app/api/components-prompt/route.ts new file mode 100644 index 0000000..ceb5979 --- /dev/null +++ b/frontend-v2/app/api/components-prompt/route.ts @@ -0,0 +1,91 @@ +/** + * API Route: 获取 json-render 组件提示词 + * + * 返回 catalog.prompt() 生成的提示词 + * 前端可以在初始化时调用此 API 获取最新提示词 + */ +import { NextResponse } from 'next/server'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; + +// 缓存提示词(5分钟过期) +let cachedPrompt: string | null = null; +let cacheTime: number = 0; +const CACHE_TTL = 5 * 60 * 1000; // 5 分钟 + +export async function GET() { + try { + // 检查缓存 + const now = Date.now(); + if (cachedPrompt && (now - cacheTime) < CACHE_TTL) { + return NextResponse.json({ + success: true, + prompt: cachedPrompt, + cached: true, + length: cachedPrompt.length, + }); + } + + // 读取预生成的提示词文件 + const promptPath = join(process.cwd(), 'lib/catalog/generated-prompt.txt'); + const prompt = await readFile(promptPath, 'utf-8'); + + // 更新缓存 + cachedPrompt = prompt; + cacheTime = now; + + return NextResponse.json({ + success: true, + prompt, + cached: false, + length: prompt.length, + generatedAt: new Date().toISOString(), + }); + } catch (error) { + console.error('Failed to read components prompt:', error); + + // 返回 fallback 提示词 + const fallbackPrompt = generateFallbackPrompt(); + + return NextResponse.json({ + success: false, + prompt: fallbackPrompt, + error: 'Failed to read generated prompt, using fallback', + length: fallbackPrompt.length, + }); + } +} + +/** + * 生成 fallback 提示词(当文件读取失败时使用) + */ +function generateFallbackPrompt(): string { + return `## 可用的 json-render 组件 + +你可以使用以下组件来展示结构化数据。 + +### 基础组件 +- card: 卡片容器 +- stack: 弹性布局 +- heading: 标题 (h1-h6) +- text: 文本 +- button: 按钮 +- badge: 徽章 +- code-block: 代码块 + +### MCP 专用组件 +- translation-result: 翻译结果 +- novel-list: 小说列表 +- chapter-reader: 章节阅读器 +- tool-call: 工具调用状态 + +组件格式示例: +\`\`\`json +{ + "type": "card", + "title": "标题", + "children": [] +} +\`\`\` +`; +} diff --git a/frontend-v2/app/page.tsx b/frontend-v2/app/page.tsx index 0b49347..86cda95 100644 --- a/frontend-v2/app/page.tsx +++ b/frontend-v2/app/page.tsx @@ -105,7 +105,7 @@ const WELCOME_SPEC: ComponentSpec = { export default function Home() { const [history, setHistory] = useState([]); - const { sendMessage, isLoading, response, error, abort } = useChat(); + const { sendMessage, isLoading, response, error, abort, specs } = useChat(); const pendingMessageRef = useRef(null); // 当加载完成时,将完整的助手消息添加到历史记录 @@ -187,7 +187,7 @@ export default function Home() { ); })} {isLoading && response && ( - + )} {error && (
diff --git a/frontend-v2/components/ChatMessage.tsx b/frontend-v2/components/ChatMessage.tsx index 5fb2869..5173531 100644 --- a/frontend-v2/components/ChatMessage.tsx +++ b/frontend-v2/components/ChatMessage.tsx @@ -1,225 +1,191 @@ /** * 聊天消息组件 - 支持 Markdown 渲染和 JSON 组件混排 * - * 功能: - * - 使用 react-markdown 渲染 Markdown - * - 自动检测 JSON 代码块,如果是有效的 json-render spec,就地渲染为组件 - * - 支持代码高亮和复制功能 - * - 支持 GitHub Flavored Markdown (GFM) + * 更新:使用官方 @json-render/core API + * - 支持从 props 直接接收编译后的 specs + * - 使用官方 Renderer 渲染组件 */ 'use client'; import { useEffect, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { jsonRenderRegistry } from '@/lib/json-render-registry'; -import type { ComponentSpec } from '@/lib/json-render-catalog'; +import { Renderer, JSONUIProvider } from '@json-render/react'; +import { compileSpecStream, parseSpecStreamLine } from '@json-render/core'; +import { registry } from '@/lib/registry'; import { useActionEmit } from '@/lib/action-context'; +import type { Spec } from '@json-render/core'; interface ChatMessageProps { role: 'user' | 'assistant'; content: string; isLoading?: boolean; + specs?: Spec[]; // 新增:接收编译后的 specs } /** - * 验证 JSON 数据是否为有效的 json-render 组件 spec - * - * 检查条件: - * 1. 是对象且包含 type 字段 - * 2. type 字段值在 jsonRenderRegistry 中存在 + * 检查文本是否为 JSON Patch 格式 (SpecStream) */ -function isValidJsonRenderSpec(data: unknown): data is ComponentSpec { - if (!data || typeof data !== 'object' || Array.isArray(data)) { - return false; +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; - 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 ( - - {children} - - ); +/** + * 从编译后的 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 ; - } - - return ; - }; - - return ; + // 如果 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 ; - } - - return ; - }; - - return ; - } - } - } 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 {children}; + } + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : 'text'; return ( -
-
+
+
{language} - +
-
-        {children}
-      
+
{children}
); } -/** - * 自定义段落渲染器 - * 处理段落中的换行 - */ -function Paragraph({ children }: { children?: React.ReactNode }) { - return ( -

- {children} -

- ); +function Paragraph({ children }: any) { + return

{children}

; } -/** - * 聊天消息组件 - */ -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 ( +
+
+
AI 助手
+
+ + {compiledSpecs.map((spec, i) => { + try { + console.log('[ChatMessage] Rendering spec:', spec); + return ; + } catch (e) { + console.error('[ChatMessage] Render error:', e); + return
渲染错误: {String(e)}
; + } + })} +
+ {isLoading && } +
+
+
+ ); + } + + // 普通 Markdown 渲染 return (
-
- {!isUser && ( -
- AI 助手 -
- )} - - {/* Markdown 渲染区域(仅 assistant 消息) */} +
+ {!isUser &&
AI 助手
} {!isUser ? (
- , - p: Paragraph, - }} - > + {content || ''} - {isLoading && ( - - )} + {isLoading && }
) : ( - /* 用户消息 - 纯文本显示 */
{content || 暂无内容}
diff --git a/frontend-v2/components/JsonRenderer.tsx b/frontend-v2/components/JsonRenderer.tsx index b61970b..11f60d8 100644 --- a/frontend-v2/components/JsonRenderer.tsx +++ b/frontend-v2/components/JsonRenderer.tsx @@ -1,303 +1,347 @@ /** - * JsonRenderer 组件 + * JsonRenderer - 使用官方 @json-render/react API 的渲染器 * - * 基于 Vercel Labs json-render 的生成式 UI 渲染器 - * 接收 AI 生成的 spec 并渲染为 React 组件 - * 支持组件交互事件(通过 ActionProvider 传递 emit 函数) + * 功能: + * - 使用官方 Renderer 组件渲染 json-render spec + * - 支持单个 spec 和 specs 数组 + * - 支持流式渲染(SSE) + * - 兼容旧格式的 ComponentSpec + * + * 使用方式: + * ```tsx + * // 渲染单个 spec(旧格式) + * + * + * // 渲染多个 specs(旧格式) + * + * + * // 渲染官方格式 spec + * + * ``` */ '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; +// ============ 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) => 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 = {}, + 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; + 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; + 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 = {}; + 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 = {}; + 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) => Promise | void> = { + sendMessage: async (params?: Record) => { + onAction?.('sendMessage', params); + }, + selectNovel: async (params?: Record) => { + onAction?.('selectNovel', params); + }, + copy: async (params?: Record) => { + 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 ( -
-
-
- Generating UI... -
-
- ); - } - - // 错误状态 - if (error) { - return ( -
-
-
- ⚠️ - Render Error -
-

{error.message}

-
-
- ); - } - - // 空状态 - if (allSpecs.length === 0) { + if (!finalSpec) { return null; } - // 渲染组件 return ( -
- {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 ; - } - - // Fallback - return ( -
-

- Unknown component type: {componentType} -

-
-              {JSON.stringify(componentSpec, null, 2)}
-            
-
- ); - })} +
+ + +
); } /** - * 用于流式渲染的 Hook - * 当 spec 通过 SSE 流式传输时使用 + * 流式 JsonRenderer 组件 + * + * 用于 SSE 流式渲染,支持动态更新 specs */ -export function useStreamingJsonRenderer() { - const [specs, setSpecs] = useState([]); - const [currentSpec, setCurrentSpec] = useState>({}); - 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) => Promise | void> = { + sendMessage: async (params?: Record) => { + onAction?.('sendMessage', params); + }, + selectNovel: async (params?: Record) => { + onAction?.('selectNovel', params); + }, + copy: async (params?: Record) => { + 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 ( +
+ + + +
+ ); } +// ============ 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 ( -
-

Tool: {tool}

-
{JSON.stringify(result, null, 2)}
-
- ); - } - - return ; -} +// 导出类型 +export type { Spec, StateModel } from '@json-render/react'; +export type { UIElement } from '@json-render/core'; +// 兼容旧类型 +export type ComponentSpec = LegacyComponentSpec; diff --git a/frontend-v2/lib/catalog/catalog.ts b/frontend-v2/lib/catalog/catalog.ts new file mode 100644 index 0000000..dffa7d6 --- /dev/null +++ b/frontend-v2/lib/catalog/catalog.ts @@ -0,0 +1,237 @@ +/** + * Catalog 定义 - 使用 Zod schemas 定义组件 props + */ +import { z } from 'zod'; +import { defineSchema, defineCatalog } from '@json-render/core'; + +// 使用 defineSchema 定义 schema +const schema = defineSchema((s) => ({ + spec: s.object({ + type: s.ref('catalog.components'), + props: s.propsOf('catalog.components'), + }), + catalog: s.object({ + components: s.map({ + props: s.zod(), + description: s.string(), + }), + }), +})); + +// 组件 props Zod schemas +export const CardProps = z.object({ + title: z.string().optional(), + variant: z.enum(['default', 'outlined', 'elevated']).optional(), + className: z.string().optional(), +}); + +export const StackProps = z.object({ + direction: z.enum(['row', 'column']).optional(), + spacing: z.number().optional(), + align: z.enum(['start', 'center', 'end', 'stretch']).optional(), + className: z.string().optional(), +}); + +export const HeadingProps = z.object({ + text: z.string(), + level: z.enum(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']).optional(), + className: z.string().optional(), +}); + +export const TextProps = z.object({ + text: z.string(), + size: z.enum(['xs', 'sm', 'md', 'lg', 'xl']).optional(), + color: z.enum(['default', 'muted', 'primary', 'success', 'warning', 'destructive']).optional(), + className: z.string().optional(), +}); + +export const ButtonProps = z.object({ + label: z.string(), + variant: z.enum(['default', 'primary', 'secondary', 'outline', 'ghost', 'destructive']).optional(), + onClick: z.string().optional(), + disabled: z.boolean().optional(), + className: z.string().optional(), +}); + +export const BadgeProps = z.object({ + text: z.string(), + variant: z.enum(['default', 'primary', 'secondary', 'success', 'warning', 'destructive']).optional(), + className: z.string().optional(), +}); + +export const SeparatorProps = z.object({ + orientation: z.enum(['horizontal', 'vertical']).optional(), + className: z.string().optional(), +}); + +export const InputProps = z.object({ + placeholder: z.string().optional(), + type: z.enum(['text', 'password', 'email', 'number']).optional(), + disabled: z.boolean().optional(), + className: z.string().optional(), +}); + +export const TextAreaProps = z.object({ + placeholder: z.string().optional(), + rows: z.number().optional(), + disabled: z.boolean().optional(), + className: z.string().optional(), +}); + +export const DataTableProps = z.object({ + columns: z.array(z.object({ + key: z.string(), + label: z.string(), + })).optional(), + data: z.array(z.record(z.string(), z.any())).optional(), + className: z.string().optional(), +}); + +// MCP 专用组件 props +export const TranslationResultProps = z.object({ + original: z.string().optional(), + translated: z.string(), + termsUsed: z.array(z.string()).optional(), + className: z.string().optional(), +}); + +export const NovelListProps = z.object({ + novels: z.array(z.object({ + id: z.union([z.string(), z.number()]), + title: z.string(), + author: z.string().optional(), + description: z.string().optional(), + chapterCount: z.number().optional(), + tags: z.array(z.string()).optional(), + })), + className: z.string().optional(), +}); + +export const ChapterReaderProps = z.object({ + novelTitle: z.string().optional(), + chapterTitle: z.string().optional(), + content: z.string(), + chapterNumber: z.number().optional(), + totalChapters: z.number().optional(), + className: z.string().optional(), +}); + +export const CodeBlockProps = z.object({ + code: z.string(), + language: z.string().optional(), + className: z.string().optional(), +}); + +export const ToolCallProps = z.object({ + toolName: z.string(), + status: z.enum(['pending', 'running', 'success', 'error']).optional(), + result: z.any().optional(), + className: z.string().optional(), +}); + +export const LoginPanelProps = z.object({ + serverName: z.string().optional(), + onLogin: z.string().optional(), + className: z.string().optional(), +}); + +export const McpStatusProps = z.object({ + serverId: z.string(), + connected: z.boolean(), + className: z.string().optional(), +}); + +export const SuggestionButtonsProps = z.object({ + suggestions: z.array(z.object({ + label: z.string(), + message: z.string().optional(), + icon: z.string().optional(), + })), + className: z.string().optional(), +}); + +// 创建 catalog +const catalog = defineCatalog(schema, { + components: { + card: { + props: CardProps, + description: '卡片容器组件,用于包装内容区块', + }, + stack: { + props: StackProps, + description: '布局容器组件,支持水平和垂直排列', + }, + heading: { + props: HeadingProps, + description: '标题组件', + }, + text: { + props: TextProps, + description: '文本组件', + }, + button: { + props: ButtonProps, + description: '按钮组件', + }, + badge: { + props: BadgeProps, + description: '徽章组件,用于标签和状态标识', + }, + separator: { + props: SeparatorProps, + description: '分隔线组件', + }, + input: { + props: InputProps, + description: '输入框组件', + }, + 'text-area': { + props: TextAreaProps, + description: '多行文本输入组件', + }, + 'data-table': { + props: DataTableProps, + description: '数据表格组件', + }, + 'translation-result': { + props: TranslationResultProps, + description: '翻译结果展示组件', + }, + 'novel-list': { + props: NovelListProps, + description: '小说列表组件', + }, + 'chapter-reader': { + props: ChapterReaderProps, + description: '章节阅读器组件', + }, + 'code-block': { + props: CodeBlockProps, + description: '代码块组件,支持语法高亮', + }, + 'tool-call': { + props: ToolCallProps, + description: 'MCP 工具调用状态组件', + }, + 'login-panel': { + props: LoginPanelProps, + description: 'MCP 登录面板组件', + }, + 'mcp-status': { + props: McpStatusProps, + description: 'MCP 服务器状态组件', + }, + 'suggestion-buttons': { + props: SuggestionButtonsProps, + description: '建议按钮组组件,用于快速选择预定义消息', + }, + }, +}); + +export default catalog; +export { schema }; + +// 兼容旧的导出 +export function getCatalogPrompt(): string { + return catalog.prompt(); +} diff --git a/frontend-v2/lib/component-registry.ts b/frontend-v2/lib/component-registry.ts index b6cae35..5c50b5c 100644 --- a/frontend-v2/lib/component-registry.ts +++ b/frontend-v2/lib/component-registry.ts @@ -1,460 +1,92 @@ /** - * 组件注册表 - 动态组件定义 - * - * 前端维护所有可用的 json-render 组件定义 - * 发送给后端,让 AI 了解可用的 UI 组件 - * - * ## 架构说明 - * - * 1. 前端在这里维护所有组件定义(组件类型、描述、Schema) - * 2. 通过 generateComponentsPrompt() 生成发送给后端的提示词 - * 3. 后端使用这些提示词构建 SYSTEM_PROMPT,无需手动修改后端代码 - * 4. 添加新组件时,只需在这里添加定义即可 + * 组件注册表 - 直接使用 catalog.prompt() 运行时生成 */ 'use client'; -/** - * 组件配置接口 - */ -interface ComponentConfig { - /** 组件类型标识 */ +import { getCatalogPrompt } from './catalog/catalog'; + +export interface ComponentConfig { type: string; - /** 组件描述 */ description: string; - /** Schema 字段说明 */ schema: Record; - /** 示例 JSON */ - example?: Record; - /** 使用场景提示 */ - usageHint?: string; } /** - * 所有可用的 json-render 组件定义 - * - * 添加新组件: - * 1. 在此对象中添加组件定义 - * 2. 无需修改后端代码 - */ -export const AVAILABLE_COMPONENTS: Record = { - // ============ 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 { + 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 = { + '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; -} +}; diff --git a/frontend-v2/lib/hooks.ts b/frontend-v2/lib/hooks.ts index ed50995..54d7aec 100644 --- a/frontend-v2/lib/hooks.ts +++ b/frontend-v2/lib/hooks.ts @@ -4,6 +4,8 @@ 'use client'; import { useState, useCallback, useRef, useEffect } from 'react'; +import { createMixedStreamParser, createSpecStreamCompiler, type SpecStreamLine } from '@json-render/core'; +import type { Spec } from '@json-render/core'; import { apiClient, type ChatMessage, type UserInfo, type UserRole } from './api-client'; // SSE 事件类型 @@ -33,6 +35,11 @@ export function useChat() { const abortRef = useRef<(() => void) | null>(null); const toolCallsRef = useRef>([]); + // 流式编译器相关 refs + const streamCompilerRef = useRef | null>(null); + const mixedParserRef = useRef | null>(null); + const textBufferRef = useRef(''); + // 保持 ref 同步 useEffect(() => { toolCallsRef.current = toolCalls; @@ -188,6 +195,30 @@ export function useChat() { setSpecs([]); setError(null); + // 初始化流式编译器和混合解析器 + streamCompilerRef.current = createSpecStreamCompiler(); + 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) => { diff --git a/frontend-v2/lib/registry.tsx b/frontend-v2/lib/registry.tsx new file mode 100644 index 0000000..eef1555 --- /dev/null +++ b/frontend-v2/lib/registry.tsx @@ -0,0 +1,474 @@ +/** + * json-render 注册表 + * + * 使用官方 defineRegistry API 创建组件注册表 + */ + +'use client'; + +import { useState } from 'react'; +import { defineRegistry, type BaseComponentProps } from '@json-render/react'; +import { useActionEmit } from '@/lib/action-context'; +import { z } from 'zod'; +import catalog, { + CardProps, + StackProps, + HeadingProps, + TextProps, + ButtonProps, + BadgeProps, + SeparatorProps, + InputProps, + TextAreaProps, + DataTableProps, + TranslationResultProps, + NovelListProps, + ChapterReaderProps, + CodeBlockProps, + ToolCallProps, + LoginPanelProps, + McpStatusProps, + SuggestionButtonsProps, +} from './catalog/catalog'; + +// ============ 基础组件 ============ + +const Card = ({ props, children }: BaseComponentProps>) => ( +
+ {props.title &&

{props.title}

} + {children} +
+); + +const Stack = ({ props, children }: BaseComponentProps>) => { + 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 ( +
+ {children} +
+ ); +}; + +const Heading = ({ props }: BaseComponentProps>) => { + const level = props.level || 'h2'; + const Tag = level as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + const sizeClasses: Record = { + 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 {props.text}; +}; + +const Text = ({ props }: BaseComponentProps>) => { + const variantClasses: Record = { + 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 = { + 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

{props.text}

; +}; + +const Button = ({ props, emit }: BaseComponentProps>) => { + const variantClasses: Record = { + 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 ( + + ); +}; + +const Badge = ({ props }: BaseComponentProps>) => { + const variantClasses: Record = { + 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 ( + + {props.text} + + ); +}; + +const Separator = ({ props }: BaseComponentProps>) => { + const orientation = props.orientation || 'horizontal'; + const orientationClass = orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full'; + return
; +}; + +const Input = ({ props }: BaseComponentProps>) => ( + +); + +const TextArea = ({ props }: BaseComponentProps>) => ( +