2
0

ChatMessage.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. /**
  2. * 聊天消息组件 - 支持 Markdown 渲染和 JSON 组件混排
  3. *
  4. * 更新:使用官方 @json-render/core API
  5. * - 支持从 props 直接接收编译后的 specs
  6. * - 使用官方 Renderer 渲染组件
  7. */
  8. 'use client';
  9. import { useEffect, useMemo } from 'react';
  10. import ReactMarkdown from 'react-markdown';
  11. import remarkGfm from 'remark-gfm';
  12. import { Renderer, JSONUIProvider } from '@json-render/react';
  13. import { compileSpecStream, parseSpecStreamLine } from '@json-render/core';
  14. import { registry } from '@/lib/registry';
  15. import { useActionEmit } from '@/lib/action-context';
  16. import type { Spec } from '@json-render/core';
  17. interface ChatMessageProps {
  18. role: 'user' | 'assistant';
  19. content: string;
  20. isLoading?: boolean;
  21. specs?: Spec[]; // 新增:接收编译后的 specs
  22. }
  23. /**
  24. * 检查文本是否为 JSON Patch 格式 (SpecStream)
  25. */
  26. function isSpecStreamFormat(text: string): boolean {
  27. const lines = text.split('\n').filter(l => l.trim());
  28. if (lines.length === 0) return false;
  29. let patchCount = 0;
  30. for (const line of lines.slice(0, 5)) {
  31. const patch = parseSpecStreamLine(line);
  32. if (patch) patchCount++;
  33. }
  34. return patchCount >= 2;
  35. }
  36. /**
  37. * 从 SpecStream 格式中编译出完整的 spec
  38. */
  39. function compileSpecFromStream(text: string): Spec | null {
  40. try {
  41. const spec = compileSpecStream(text as any);
  42. console.log('[compileSpecFromStream] Compiled spec:', spec);
  43. return spec;
  44. } catch (e) {
  45. console.error('[compileSpecFromStream] Failed to compile:', e);
  46. return null;
  47. }
  48. }
  49. /**
  50. * 从编译后的 spec 中提取可渲染的元素
  51. * 对于 flat 格式 {root, elements},返回 root 指向的元素
  52. */
  53. function extractRenderableElements(spec: Spec): Spec[] {
  54. const elements: Spec[] = [];
  55. if (!spec || typeof spec !== 'object') return elements;
  56. // 如果 spec 有 root 和 elements 字段(flat 格式)
  57. // 需要渲染整个 spec 对象,让 Renderer 根据 root 找到对应元素
  58. if (spec.root && spec.elements && typeof spec.elements === 'object') {
  59. // 返回完整的 flat spec,Renderer 会处理
  60. elements.push(spec);
  61. return elements;
  62. }
  63. // 如果 spec 有 elements 字段但没有 root
  64. if (spec.elements && typeof spec.elements === 'object') {
  65. for (const [key, element] of Object.entries(spec.elements)) {
  66. if (element && typeof element === 'object' && 'type' in element) {
  67. elements.push(element as Spec);
  68. }
  69. }
  70. }
  71. // 如果 spec 本身就是一个组件
  72. if ('type' in spec && spec.type) {
  73. elements.push(spec);
  74. }
  75. return elements;
  76. }
  77. function CodeBlock({ inline, className, children, ...props }: any) {
  78. const code = String(children).replace(/\n$/, '');
  79. if (inline) {
  80. return <code className="bg-gray-100 dark:bg-gray-900 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>{children}</code>;
  81. }
  82. const match = /language-(\w+)/.exec(className || '');
  83. const language = match ? match[1] : 'text';
  84. return (
  85. <div className="code-block bg-gray-900 rounded-lg overflow-x-auto my-2">
  86. <div className="flex items-center justify-between px-4 py-2 bg-gray-800 border-b border-gray-700">
  87. <span className="text-xs text-gray-400 uppercase">{language}</span>
  88. <button onClick={() => navigator.clipboard.writeText(code)} className="text-xs text-gray-400 hover:text-white">复制</button>
  89. </div>
  90. <pre className="p-4 text-sm text-gray-100 font-mono overflow-x-auto"><code {...props}>{children}</code></pre>
  91. </div>
  92. );
  93. }
  94. function Paragraph({ children }: any) {
  95. return <p className="my-2 leading-relaxed">{children}</p>;
  96. }
  97. export default function ChatMessage({ role, content, isLoading, specs: propSpecs }: ChatMessageProps) {
  98. const isUser = role === 'user';
  99. const { emit } = useActionEmit();
  100. // 优先使用传入的 specs,否则从 content 编译
  101. const compiledSpecs = useMemo(() => {
  102. // 如果 props 传入了 specs,直接使用
  103. if (propSpecs && propSpecs.length > 0) {
  104. console.log('[ChatMessage] Using propSpecs:', propSpecs);
  105. return propSpecs;
  106. }
  107. if (isUser || !content) return [];
  108. // 回退:从 content 编译
  109. if (isSpecStreamFormat(content)) {
  110. console.log('[ChatMessage] Detected SpecStream format, compiling...');
  111. const spec = compileSpecFromStream(content);
  112. if (spec) {
  113. const elements = extractRenderableElements(spec);
  114. console.log('[ChatMessage] Extracted', elements.length, 'elements from content');
  115. return elements;
  116. }
  117. }
  118. return [];
  119. }, [content, isUser, propSpecs]);
  120. useEffect(() => {
  121. if (!isUser) {
  122. console.log('[ChatMessage] Rendering:', {
  123. hasPropSpecs: !!(propSpecs && propSpecs.length > 0),
  124. isSpecStream: isSpecStreamFormat(content || ''),
  125. compiledSpecsCount: compiledSpecs.length,
  126. contentPreview: content?.substring(0, 100),
  127. });
  128. }
  129. }, [content, isUser, compiledSpecs, propSpecs]);
  130. // 如果有编译出的 specs,渲染组件
  131. if (compiledSpecs.length > 0) {
  132. return (
  133. <div className="flex justify-start">
  134. <div className="message-bubble assistant-message max-w-full">
  135. <div className="text-xs text-gray-500 dark:text-gray-400 mb-2">AI 助手</div>
  136. <div className="space-y-4">
  137. <JSONUIProvider registry={registry}>
  138. {compiledSpecs.map((spec, i) => {
  139. try {
  140. console.log('[ChatMessage] Rendering spec:', spec);
  141. return <Renderer key={i} registry={registry} spec={spec} state={spec.state} />;
  142. } catch (e) {
  143. console.error('[ChatMessage] Render error:', e);
  144. return <div key={i} className="p-2 bg-red-50 rounded text-sm text-red-600">渲染错误: {String(e)}</div>;
  145. }
  146. })}
  147. </JSONUIProvider>
  148. {isLoading && <span className="inline-block ml-1 animate-pulse">▊</span>}
  149. </div>
  150. </div>
  151. </div>
  152. );
  153. }
  154. // 普通 Markdown 渲染
  155. return (
  156. <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
  157. <div className={`message-bubble ${isUser ? 'user-message' : 'assistant-message'}`}>
  158. {!isUser && <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">AI 助手</div>}
  159. {!isUser ? (
  160. <div className="prose prose-sm dark:prose-invert max-w-none">
  161. <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: CodeBlock, p: Paragraph }}>
  162. {content || ''}
  163. </ReactMarkdown>
  164. {isLoading && <span className="inline-block ml-1 animate-pulse">▊</span>}
  165. </div>
  166. ) : (
  167. <div className="whitespace-pre-wrap break-words">
  168. {content || <span className="text-gray-400 italic">暂无内容</span>}
  169. </div>
  170. )}
  171. </div>
  172. </div>
  173. );
  174. }