| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- /**
- * 聊天消息组件 - 支持 Markdown 渲染和 JSON 组件混排
- *
- * 功能:
- * - 使用 react-markdown 渲染 Markdown
- * - 自动检测 JSON 代码块,如果是有效的 json-render spec,就地渲染为组件
- * - 支持代码高亮和复制功能
- * - 支持 GitHub Flavored Markdown (GFM)
- */
- '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 { useActionEmit } from '@/lib/action-context';
- interface ChatMessageProps {
- role: 'user' | 'assistant';
- content: string;
- isLoading?: boolean;
- }
- /**
- * 验证 JSON 数据是否为有效的 json-render 组件 spec
- *
- * 检查条件:
- * 1. 是对象且包含 type 字段
- * 2. type 字段值在 jsonRenderRegistry 中存在
- */
- function isValidJsonRenderSpec(data: unknown): data is ComponentSpec {
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
- return false;
- }
- const obj = data as Record<string, unknown>;
- const type = obj.type;
- if (typeof type !== 'string') {
- return false;
- }
- // 检查 type 是否在注册表中
- return type in jsonRenderRegistry;
- }
- /**
- * 自定义代码块渲染器
- *
- * 逻辑:
- * 1. 检测是否是 JSON 代码块
- * 2. 尝试解析 JSON
- * 3. 如果是有效的 json-render spec,渲染为组件
- * 4. 否则渲染为普通代码块
- */
- 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$/, '');
- // 内联代码直接渲染
- 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>
- );
- }
- // 提取语言标识
- 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
- 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 解析失败,继续渲染为普通代码块
- }
- }
- // 渲染为普通代码块
- 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">
- <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>
- </div>
- <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>
- );
- }
- /**
- * 聊天消息组件
- */
- export default function ChatMessage({ role, content, isLoading }: ChatMessageProps) {
- const isUser = role === 'user';
- const { emit } = useActionEmit();
- // 调试日志
- useEffect(() => {
- if (!isUser) {
- console.log('[ChatMessage] Rendering assistant message:', {
- contentLength: content?.length || 0,
- contentPreview: content?.substring(0, 100),
- isLoading,
- });
- }
- }, [content, isLoading, isUser]);
- 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 消息) */}
- {!isUser ? (
- <div className="prose prose-sm dark:prose-invert max-w-none">
- <ReactMarkdown
- remarkPlugins={[remarkGfm]}
- components={{
- code: (props) => <CodeBlock {...props} emit={emit} />,
- 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>
- );
- }
|