| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- /**
- * 聊天消息组件 - 支持 Markdown 渲染和 JSON 组件混排
- *
- * 更新:使用官方 @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 { 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 Patch 格式 (SpecStream)
- */
- 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++;
- }
- return patchCount >= 2;
- }
- /**
- * 从 SpecStream 格式中编译出完整的 spec
- */
- 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;
- }
- }
- /**
- * 从编译后的 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;
- }
- // 如果 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 ('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 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">复制</button>
- </div>
- <pre className="p-4 text-sm text-gray-100 font-mono overflow-x-auto"><code {...props}>{children}</code></pre>
- </div>
- );
- }
- function Paragraph({ children }: any) {
- return <p className="my-2 leading-relaxed">{children}</p>;
- }
- 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:', {
- hasPropSpecs: !!(propSpecs && propSpecs.length > 0),
- isSpecStream: isSpecStreamFormat(content || ''),
- compiledSpecsCount: compiledSpecs.length,
- contentPreview: content?.substring(0, 100),
- });
- }
- }, [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} state={spec.state} />;
- } 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>}
- {!isUser ? (
- <div className="prose prose-sm dark:prose-invert max-w-none">
- <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: CodeBlock, p: Paragraph }}>
- {content || ''}
- </ReactMarkdown>
- {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>
- )}
- </div>
- </div>
- );
- }
|