如何在markdown中,将JSON渲染成自定义React组件

309 阅读3分钟

背景:

  • AI助手返回的markdown格式文本,渲染成markdown文件,并将代码高亮展示
  • markdown格式文本中JSON配置,按照自定义组件样式展示

markdown展示图

image.png

image.png

image.png

将JSON渲染成自定义组件效果图

image.png

解决思路

  • 引入marked、marked-highlight、highlight.js渲染markdown格式文本
  • 使用react-dom/server的ReactDOMServer将React组件渲染成静态HTML标签
  • 代码块添加按钮、事件

详细实现

import { useEffect, useState } from 'react'
import ReactDOMServer from 'react-dom/server';
import { Button, message } from 'antd';
// 自定义组件
import StaticDraggerTree from '@/components/DraggerTree/StaticTree'
import { Provider } from 'react-redux';
import { Marked, Renderer } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/base16/darcula.css'

import './index.less';

const mockStore = {
  subscribe: () => {},
  dispatch: () => {},
  getState: () => ({}),
};
 

const AIDialogue = (props) => {

  const { content } = props;
  const [parseHtml, setParseHtml] = useState('')

  function removeCodeBlockMarker(code) {
    return code.replace(/```[\w\s-]*\n([\s\S]+?)\n```/g, '$1');
  }

  const renderer = new Renderer()
  renderer.code = function (code, lang) {
    const highlightedCode = hljs.highlightAuto(removeCodeBlockMarker(code.raw)).value;

    // 判断是否是需要渲染成自定义组件的配置json,如果是,需将json转换成自定义组件的输入格式
    let isServiceOrch = false;
    try{ // content为流式文件返回时,添加try catch防止JSON不完整时解析报错
      if (code.lang == 'json') {
        // 解析成JSON对象
        const dataJson = JSON.parse(removeCodeBlockMarker(code.raw));
        if (dataJson) { // 具体为JSON满足的自定义渲染条件
          isServiceOrch = true;
        }
      }
    }catch(e){
      console.log(e)
    }
    
    if (isServiceOrch) {
      const randomId = Math.random().toString(36).substring(2, 10)
      const jsonObj = JSON.parse(removeCodeBlockMarker(code.raw));
      const children = jsonObj.children || jsonObj.defaultConfig?.children;
      const reactEle = ReactDOMServer.renderToString(
        <div class="mermaid-block" data-mermaid-id={randomId}>
          <Provider store={mockStore}>
            <StaticDraggerTree nodeTreeData={{children}} />
          </Provider>
          <Button class="so-service-action" data-action="create" data-mermaid-id={randomId}>
            创建编排
          </Button>
          <div class="mermaid-code-view" id={`mermaid-code-${randomId}`}>
            {/* 大模型生成的原始json,创建编排时使用。此处不需展示,故隐藏 */}
            <pre style={{"display": "none"}}><coderaw class="hljs language-mermaid">{removeCodeBlockMarker(code.raw)}</coderaw></pre>
          </div>
        </div>
      );
      return `${reactEle}`
    } 
    else {
      if (code.lang) {
        return `<pre><code class="hljs language-${code.lang}">${highlightedCode}</code></pre>`
      } else {
        return `<pre><code class="hljs">${highlightedCode}</code></pre>`
      }
    }
  }

  const marked = new Marked(
    markedHighlight({
      langPrefix: 'hljs language-',
      highlight(code, lang) {
        const language = hljs.getLanguage(lang) ? lang : 'plaintext'
        return hljs.highlight(code, { language }).value
      }
    })
  )
  marked.use({
    extensions: [
      {
        name: 'thinkBlock',
        level: 'block',
        start(src) {
          return src.match(/<think>/i)?.index
        },
        tokenizer(src) {
          const rule = /^<think>([\s\S]*?)<\/think>/i
          const match = rule.exec(src)
          if (match) {
            return {
              type: 'thinkBlock',
              raw: match[0],
              text: match[1].trim()
            }
          }
        },
        renderer(token) {
          return `<think>${token.text}</think>`
        }
      }
    ]
  })
  marked.setOptions({
    renderer,  //默认render,如需特殊配置,可打开注释
    highlight: function (code) {
      return hljs.highlightAuto(code).value
    },
    gfm: true, // 允许 Git Hub标准的markdown.
    pedantic: false, // 不纠正原始模型任何的不良行为和错误(默认为false)
    sanitize: false, // 对输出进行过滤(清理),将忽略任何已经输入的html代码(标签)
    tables: true, // 允许支持表格语法(该选项要求 gfm 为true)
    breaks: false, // 允许回车换行(该选项要求 gfm 为true)
    smartLists: true, // 使用比原生markdown更时髦的列表
    smartypants: false, // 使用更为时髦的标点
  })

  // 添加代码块按钮
  const addCodeBlockButtons = (outputIndex) => {
    const outputDiv = document.getElementById(`outPut${outputIndex}`)
    if (!outputDiv) return

    const preBlocks = outputDiv.querySelectorAll('pre:not(.processed)')
    console.log("preBlocks:", preBlocks)
    preBlocks.forEach((preBlock, i) => {
      const blockIndex = `${outputIndex}_${i}`
      preBlock.id = `output_pre_${blockIndex}`
      const div = document.createElement('div')
      div.classList.add('insert-code-btn')
      div.innerHTML = `
      <div class="inner-btns">
        <div class="copybtn-icn" id="codeCopy_${blockIndex}" title="复制">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6Z"/></svg>
        </div>
        <div class="copybtn-icn" id="codeInsert_${blockIndex}" title="插入">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM20 12L16.4645 15.5355L15.0503 14.1213L17.1716 12L15.0503 9.87868L16.4645 8.46447L20 12ZM6.82843 12L8.94975 14.1213L7.53553 15.5355L4 12L7.53553 8.46447L8.94975 9.87868L6.82843 12ZM11.2443 17H9.11597L12.7557 7H14.884L11.2443 17Z"/></svg>
        </div>
        <div class="copybtn-icn" id="codeReplace_${blockIndex}" title="替换">
          <svg viewBox="0 0 1024 1024" fill="currentColor"><path d="M480.036571 464.018286v-384a16.018286 16.018286 0 0 0-16.091428-16.018286h-384a16.018286 16.018286 0 0 0-15.945143 16.018286v384c0 8.777143 7.168 15.945143 16.018286 15.945143h384c8.777143 0 15.945143-7.094857 15.945143-15.945143zM399.945143 144.018286v256h-256v-256h256zM512 156.013714v71.972572h220.013714V384h-44.032a8.045714 8.045714 0 0 0-6.363428 12.8l80.018285 106.642286a8.045714 8.045714 0 0 0 12.8 0l79.945143-106.642286a8.045714 8.045714 0 0 0-6.363428-12.8h-44.032V208.018286a51.931429 51.931429 0 0 0-51.931429-52.004572H512zM175.981714 640a8.045714 8.045714 0 0 1-6.363428-12.8l79.945143-106.642286a8.045714 8.045714 0 0 1 12.8 0l80.018285 106.642286a8.045714 8.045714 0 0 1-6.363428 12.8h-44.032v156.013714H512v71.972572H272.018286a51.931429 51.931429 0 0 1-52.004572-51.931429V640h-44.032z m784.018286 303.981714v-384a16.018286 16.018286 0 0 0-16.018286-15.945143h-384a16.018286 16.018286 0 0 0-15.945143 15.945143v384c0 8.850286 7.094857 16.018286 15.945143 16.018286h384c8.850286 0 16.018286-7.168 16.018286-16.018286z m-80.018286-320v256h-256v-256h256z"></path></svg>
        </div>
      </div>`
      preBlock.prepend(div)
      preBlock.classList.add('processed')
    })
  }

  // 代码块按钮事件
  function codeBlockButtonEvent(outputIndex) {
    const outputDiv = document.getElementById(`outPut${outputIndex}`)
    if (!outputDiv) return

    const preBlocks = outputDiv.querySelectorAll('pre')

    preBlocks.forEach((preBlock, i) => {
      const blockIndex = `${outputIndex}_${i}`
      // 复制代码
      const codeCopyBtn = document.getElementById(`codeCopy_${blockIndex}`)

      if (codeCopyBtn && !codeCopyBtn.dataset.listenerAdded) {
        codeCopyBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCopy ? props.onCopy({
            message: code,
            id: props.id,
            chatId: props.chatId
          }) : null;
        })
        codeCopyBtn.dataset.listenerAdded = 'true'
      }

      // 插入代码
      const codeInsertBtn = document.getElementById(`codeInsert_${blockIndex}`)

      if (codeInsertBtn && !codeInsertBtn.dataset.listenerAdded) {
        codeInsertBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCodeInsert ? props.onCodeInsert(code) : null;
        })
        codeInsertBtn.dataset.listenerAdded = 'true'
      }

      // 替换代码
      const codeReplaceBtn = document.getElementById(`codeReplace_${blockIndex}`)

      if (codeReplaceBtn && !codeReplaceBtn.dataset.listenerAdded) {
        codeReplaceBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCodeReplace ? props.onCodeReplace(code) : null;
        })
        codeReplaceBtn.dataset.listenerAdded = 'true'
      }
    })
  }

  /**
   * 根据不同语言,设置不同的对应的代码提示
   * */
  useEffect(() => {
    let contentMock = "根据您的需求,我们需要创建一个服务编排配置,该配置首先查询用户表的信息,然后将查询结果导出到Excel文件。以下是详细的JSON配置:\n\n```json\n{\n  \"title\": \"查询用户表信息并导出到Excel\",\n  \"nodeType\": \"START\",\n  \"children\": [\n    {\n      \"title\": \"查询用户表信息\",\n      \"nodeType\": \"DATASOURCE\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"1\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"SQLForm\",\n      \"dsKey\": \"yourDataSourceKey\",  # 替换为实际的数据源ID\n      \"sql\": \"SELECT id, username, email FROM users;\",  # 替换为实际的SQL查询语句\n      \"targetName\": \"output.users\",\n      \"shareType\": 0,\n      \"justArray\": true\n    },\n    {\n      \"title\": \"JS代码 - 数据格式转换\",\n      \"nodeType\": \"JS\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"2\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"JSCodeForm\",\n      \"source\": \"// 将查询结果转换为适合导出Excel的格式\\nlet users = state.output.users;\\nlet excelData = [['ID', 'Username', 'Email']];\\nusers.forEach(user => {\\n    excelData.push([user.id, user.username, user.email]);\\n});\\nstate.output.excelData = excelData;\\nreturn state;\"\n    },\n    {\n      \"title\": \"导出Excel\",\n      \"nodeType\": \"EE\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"3\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"DeriveExcelForm\",\n      \"multipleSheets\": false,\n      \"exportData\": \"{output.excelData}\",\n      \"targetName\": \"output.filePath\"\n    },\n    {\n      \"title\": \"JS代码 - 输出文件地址\",\n      \"nodeType\": \"JS\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"4\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"JSCodeForm\",\n      \"source\": \"// 返回文件路径\\nstate.output.response = {\\n    filePath: state.output.filePath,\\n    status: 'Success'\\n};\\nreturn state;\"\n    }\n  ],\n  \"expression\": null,\n  \"id\": \"0\",\n  \"parentNodeType\": null,\n  \"setVerify\": null,\n  \"formName\": null\n}\n```\n\n### 配置说明\n\n1. **查询用户表信息**:\n   - 使用`DATASOURCE`节点执行SQL查询,查询用户表的所有记录。\n\n2. **JS代码 - 数据格式转换**:\n   - 使用`JS`节点将查询结果转换为适合导出Excel的格式。这里我们将查询结果转换成了一个二维数组的形式,其中第一行为表头,后续行为数据行。\n\n3. **导出Excel**:\n   - 使用`EE`节点将转换好的数据导出为Excel文件,并将文件路径存储在`output.filePath`中。\n\n4. **JS代码 - 输出文件地址**:\n   - 最后一步再次使用`JS`节点,将文件路径和其他相关信息构造成最终的响应对象,方便查看和使用。\n\n请注意替换`yourDataSourceKey`为您实际的数据源ID,并调整SQL查询语句以适应您数据库的实际表结构。"
    // 此处测试用contentMock,实际用content
    setParseHtml(marked.parse(contentMock));

    setTimeout(() => {
      // 在这里执行依赖于最新DOM的操作
      addCodeBlockButtons(props.id) // 添加代码块操作按钮
      codeBlockButtonEvent(props.id) // 添加代码块按钮事件
      setupMermaidActions() // 添加自定义组件按钮事件
    }, 100);

  }, [content])


  return (
    <>
      <div className="ai-markdown">
        <div className="markdown-view" dangerouslySetInnerHTML={{ __html: parseHtml }} id={'outPut' + props.id}></div>
      </div >
    </>
  );
};
export default AIDialogue;