2
0

ChatMessage.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /**
  2. * 聊天消息组件 - 支持 Markdown 渲染和 JSON 组件混排
  3. *
  4. * 功能:
  5. * - 使用 react-markdown 渲染 Markdown
  6. * - 自动检测 JSON 代码块,如果是有效的 json-render spec,就地渲染为组件
  7. * - 支持代码高亮和复制功能
  8. * - 支持 GitHub Flavored Markdown (GFM)
  9. */
  10. 'use client';
  11. import { useEffect, useMemo } from 'react';
  12. import ReactMarkdown from 'react-markdown';
  13. import remarkGfm from 'remark-gfm';
  14. import { jsonRenderRegistry } from '@/lib/json-render-registry';
  15. import type { ComponentSpec } from '@/lib/json-render-catalog';
  16. import { useActionEmit } from '@/lib/action-context';
  17. interface ChatMessageProps {
  18. role: 'user' | 'assistant';
  19. content: string;
  20. isLoading?: boolean;
  21. }
  22. /**
  23. * 验证 JSON 数据是否为有效的 json-render 组件 spec
  24. *
  25. * 检查条件:
  26. * 1. 是对象且包含 type 字段
  27. * 2. type 字段值在 jsonRenderRegistry 中存在
  28. */
  29. function isValidJsonRenderSpec(data: unknown): data is ComponentSpec {
  30. if (!data || typeof data !== 'object' || Array.isArray(data)) {
  31. return false;
  32. }
  33. const obj = data as Record<string, unknown>;
  34. const type = obj.type;
  35. if (typeof type !== 'string') {
  36. return false;
  37. }
  38. // 检查 type 是否在注册表中
  39. return type in jsonRenderRegistry;
  40. }
  41. /**
  42. * 自定义代码块渲染器
  43. *
  44. * 逻辑:
  45. * 1. 检测是否是 JSON 代码块
  46. * 2. 尝试解析 JSON
  47. * 3. 如果是有效的 json-render spec,渲染为组件
  48. * 4. 否则渲染为普通代码块
  49. */
  50. function CodeBlock({
  51. inline,
  52. className,
  53. children,
  54. emit,
  55. ...props
  56. }: {
  57. inline?: boolean;
  58. className?: string;
  59. children?: React.ReactNode;
  60. emit?: (eventName: string, payload?: any) => void;
  61. [key: string]: any;
  62. }) {
  63. const code = String(children).replace(/\n$/, '');
  64. // 内联代码直接渲染
  65. if (inline) {
  66. return (
  67. <code
  68. 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"
  69. {...props}
  70. >
  71. {children}
  72. </code>
  73. );
  74. }
  75. // 提取语言标识
  76. const match = /language-(\w+)/.exec(className || '');
  77. const language = match ? match[1] : 'text';
  78. // 检测是否是 JSON 代码块
  79. const isJson = language === 'json' || language === 'jsonc';
  80. if (isJson) {
  81. try {
  82. const parsed = JSON.parse(code);
  83. // 检查是否是有效的 json-render spec
  84. if (isValidJsonRenderSpec(parsed)) {
  85. // 使用 JsonRenderer 渲染,传入 emit 函数
  86. const JsonRenderer = require('@/components/JsonRenderer').default;
  87. // 包装 JsonRenderer,提供 emit 函数
  88. const SpecComponent = () => {
  89. // 创建一个内部组件,使用 ActionContext
  90. const { ActionContext } = require('@/lib/action-context');
  91. const { useContext } = require('react');
  92. function WrappedSpec() {
  93. const actionContext = useContext(ActionContext);
  94. return <JsonRenderer spec={parsed as ComponentSpec} />;
  95. }
  96. return <WrappedSpec />;
  97. };
  98. return <SpecComponent />;
  99. }
  100. // 如果是数组,检查是否每个元素都是有效的 spec
  101. if (Array.isArray(parsed) && parsed.length > 0) {
  102. const allValidSpecs = parsed.every(isValidJsonRenderSpec);
  103. if (allValidSpecs) {
  104. const JsonRenderer = require('@/components/JsonRenderer').default;
  105. const SpecsComponent = () => {
  106. const { ActionContext } = require('@/lib/action-context');
  107. const { useContext } = require('react');
  108. function WrappedSpecs() {
  109. const actionContext = useContext(ActionContext);
  110. return <JsonRenderer specs={parsed as ComponentSpec[]} />;
  111. }
  112. return <WrappedSpecs />;
  113. };
  114. return <SpecsComponent />;
  115. }
  116. }
  117. } catch {
  118. // JSON 解析失败,继续渲染为普通代码块
  119. }
  120. }
  121. // 渲染为普通代码块
  122. return (
  123. <div className="code-block bg-gray-900 dark:bg-gray-950 rounded-lg overflow-x-auto my-2">
  124. <div className="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-gray-900 border-b border-gray-700">
  125. <span className="text-xs text-gray-400 uppercase">{language}</span>
  126. <button
  127. onClick={() => navigator.clipboard.writeText(code)}
  128. className="text-xs text-gray-400 hover:text-white transition-colors"
  129. >
  130. 复制
  131. </button>
  132. </div>
  133. <pre className="p-4 text-sm text-gray-100 font-mono overflow-x-auto">
  134. <code {...props}>{children}</code>
  135. </pre>
  136. </div>
  137. );
  138. }
  139. /**
  140. * 自定义段落渲染器
  141. * 处理段落中的换行
  142. */
  143. function Paragraph({ children }: { children?: React.ReactNode }) {
  144. return (
  145. <p className="my-2 leading-relaxed">
  146. {children}
  147. </p>
  148. );
  149. }
  150. /**
  151. * 聊天消息组件
  152. */
  153. export default function ChatMessage({ role, content, isLoading }: ChatMessageProps) {
  154. const isUser = role === 'user';
  155. const { emit } = useActionEmit();
  156. // 调试日志
  157. useEffect(() => {
  158. if (!isUser) {
  159. console.log('[ChatMessage] Rendering assistant message:', {
  160. contentLength: content?.length || 0,
  161. contentPreview: content?.substring(0, 100),
  162. isLoading,
  163. });
  164. }
  165. }, [content, isLoading, isUser]);
  166. return (
  167. <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
  168. <div
  169. className={`message-bubble ${
  170. isUser ? 'user-message' : 'assistant-message'
  171. }`}
  172. >
  173. {!isUser && (
  174. <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
  175. AI 助手
  176. </div>
  177. )}
  178. {/* Markdown 渲染区域(仅 assistant 消息) */}
  179. {!isUser ? (
  180. <div className="prose prose-sm dark:prose-invert max-w-none">
  181. <ReactMarkdown
  182. remarkPlugins={[remarkGfm]}
  183. components={{
  184. code: (props) => <CodeBlock {...props} emit={emit} />,
  185. p: Paragraph,
  186. }}
  187. >
  188. {content || ''}
  189. </ReactMarkdown>
  190. {isLoading && (
  191. <span className="inline-block ml-1 animate-pulse">▊</span>
  192. )}
  193. </div>
  194. ) : (
  195. /* 用户消息 - 纯文本显示 */
  196. <div className="whitespace-pre-wrap break-words">
  197. {content || <span className="text-gray-400 italic">暂无内容</span>}
  198. </div>
  199. )}
  200. </div>
  201. </div>
  202. );
  203. }