Files
qiming/qiming-mobile/docs/Markdown-Custom-Renderer-Guide.md

972 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Chat Markdown 自定义渲染元素(组件)实现指南
## 📋 目录
- [概述](#概述)
- [核心架构](#核心架构)
- [快速开始:生成 Markdown 并插入自定义标签](#快速开始生成-markdown-并插入自定义标签)
- [实现步骤:创建自定义渲染组件](#实现步骤创建自定义渲染组件)
- [现有示例](#现有示例)
- [最佳实践](#最佳实践)
- [常见问题](#常见问题)
## 概述
本项目使用 `mp-html` 组件作为 Markdown 渲染引擎,通过 `markdown-it-container` 插件机制实现自定义元素的渲染。当需要在 Markdown 中渲染自定义组件时(如工具调用、执行过程等),可以通过创建容器块来扩展渲染能力。
### 核心优势
-**插件化设计**:通过 `markdown-it-container` 插件扩展,不影响核心渲染逻辑
-**类型安全**:支持 UTS/TypeScript 类型定义
-**灵活扩展**:可以自定义任意容器标签的渲染
-**动态数据**:支持通过 `processingList` 传递动态数据,实现实时更新
-**多端支持**:基于 uni-app-x支持 H5、小程序、App 多端
## 核心架构
### 技术栈
- **渲染引擎**`mp-html` 组件(基于 `markdown-it`
- **插件系统**`markdown-it-container`
- **容器组件**`container.vue` 自定义组件
- **数据处理**`processingList` + `getProcessingDataByPriority`
### 架构图
```
┌─────────────────────────────────────────┐
│ Markdown 文本 │
│ (包含 :::container 语法) │
└──────────────┬──────────────────────────┘
│ markdown-it 解析
┌─────────────────────────────────────────┐
│ markdown-it-container 插件 │
│ - 识别 :::container 语法 │
│ - 转换为 <container> 标签 │
└──────────────┬──────────────────────────┘
│ 生成 HTML 节点
┌─────────────────────────────────────────┐
│ mp-html 组件 │
│ (uni_modules/mp-html) │
└──────────────┬──────────────────────────┘
│ 识别 container 标签
┌─────────────────────────────────────────┐
│ node.vue 组件 │
│ - 识别 <container> 标签 │
│ - 调用 getRenderData() │
│ - 合并 processingList 数据 │
└──────────────┬──────────────────────────┘
│ 传递合并后的数据
┌─────────────────────────────────────────┐
│ container.vue 组件 │
│ - 渲染工具调用状态 │
│ - 显示执行详情 │
│ - 处理用户交互 │
└─────────────────────────────────────────┘
```
### 数据流
```
Markdown 文本
:::container executeId="xxx" type="Page" status="EXECUTING"
:::
<container data='{"executeId":"xxx","type":"Page","status":"EXECUTING"}'></container>
container.vue 组件接收 data 属性
getRenderData() 合并 processingList 数据
最终渲染的组件数据 = data + processingList[executeId]
```
## 快速开始:生成 Markdown 并插入自定义标签
在实际开发中,我们通常需要在代码中动态生成包含自定义容器标签的 Markdown 字符串。本节介绍如何在前端生成这些标签。
### 方式一:使用工具函数(推荐)
#### 1. 使用现有的工具函数
项目已提供 `containerHelper.uts` 工具函数,位于 `subpackages/utils/containerHelper.uts`
**核心函数**
```uts
/**
* 生成工具调用自定义块
* @param beforeText 之前的文本内容
* @param toolCallData 工具调用数据
* @returns 包含自定义块的完整文本
*/
export function getCustomBlock(
beforeText: string,
toolCallData: {
type?: string;
name?: string;
executeId?: string;
status?: string
}
): string
```
**使用示例**
```uts
import { getCustomBlock } from '@/subpackages/utils/containerHelper.uts'
// 在消息处理中生成容器块
const toolCallData = {
type: 'Page',
name: '页面预览',
executeId: 'exec-123',
status: 'EXECUTING'
}
let markdown = '这是一段普通文本。'
markdown = getCustomBlock(markdown, toolCallData)
// 结果: '这是一段普通文本。\n\n:::container executeId="exec-123" type="Page" status="EXECUTING" name="页面预览"\n:::\n\n'
```
#### 2. 底层工具函数
如果需要更灵活的控制,可以使用底层函数:
```uts
import { getBlockWrapper, getBlockName } from '@/subpackages/utils/containerHelper.uts'
/**
* 生成自定义容器块
* @param data 属性数据对象
* @returns 格式化的容器块字符串
*/
function generateContainerBlock(data: Record<string, any>): string {
const blockName = getBlockName() // 返回 'container'
return getBlockWrapper(blockName, data)
}
// 使用示例
const block = generateContainerBlock({
executeId: 'exec-123',
type: 'Page',
status: 'EXECUTING',
name: '页面预览'
})
// 结果: '\n\n:::container executeId="exec-123" type="Page" status="EXECUTING" name="页面预览"\n:::\n\n'
```
### 方式二:直接字符串拼接
如果只是偶尔使用,也可以直接拼接字符串:
```uts
// 准备数据
const executeId = 'exec-123'
const type = 'Page'
const status = 'EXECUTING'
const name = '页面预览'
// 拼接 Markdown
const markdown = `这是一段文本。
:::container executeId="${executeId}" type="${type}" status="${status}" name="${name}"
:::
继续其他内容...`
```
### 方式三:创建专用的生成函数
对于特定的业务场景,可以创建专门的生成函数:
```uts
/**
* 生成页面预览容器块
* @param markdownText 现有的 Markdown 文本
* @param pageData 页面数据
* @returns 插入容器块后的 Markdown
*/
export function insertPagePreviewBlock(
markdownText: string,
pageData: { executeId: string; name: string; status: string }
): string {
const block = `\n\n:::container executeId="${pageData.executeId}" type="Page" status="${pageData.status}" name="${pageData.name}"\n:::\n\n`
// 检查是否已存在,避免重复插入
if (markdownText.includes(`executeId="${pageData.executeId}"`)) {
return markdownText
}
return `${markdownText}${block}`
}
/**
* 生成工具调用容器块
* @param markdownText 现有的 Markdown 文本
* @param toolCallData 工具调用数据
* @returns 插入容器块后的 Markdown
*/
export function insertToolCallBlock(
markdownText: string,
toolCallData: { executeId: string; type: string; name: string; status: string }
): string {
const attrs = Object.entries(toolCallData)
.map(([key, value]) => `${key}="${value}"`)
.join(' ')
const block = `\n\n:::container ${attrs}\n:::\n\n`
// 检查是否已存在
if (markdownText.includes(`executeId="${toolCallData.executeId}"`)) {
return markdownText
}
return `${markdownText}${block}`
}
```
### 数据传递规则
#### 1. 简单属性(字符串、数字)
直接在容器语法中传递:
```markdown
:::container executeId="exec-123" type="Page" status="EXECUTING" name="页面预览"
:::
```
#### 2. 复杂数据(对象、数组)
如果需要传递复杂数据,有两种方式:
**方式一:通过 processingList 传递(推荐)**
```uts
// 在消息对象中设置 processingList
const message = {
text: markdownWithContainer,
processingList: [
{
executeId: 'exec-123',
type: AgentComponentTypeEnum.Page,
name: '页面预览',
status: ProcessingEnum.EXECUTING,
result: {
input: { url: 'https://example.com' },
data: { html: '<div>...</div>' }
},
// ... 其他字段
}
]
}
```
**方式二:在容器语法中传递 JSON 字符串**
```markdown
:::container executeId="exec-123" data='{"key":"value","nested":{"prop":123}}'
:::
```
### 实际使用场景
#### 场景 1SSE 流式消息处理
```uts
// 在 AgentDetailService.uts 中
import { getCustomBlock } from '@/subpackages/utils/containerHelper.uts'
// 处理 PROCESSING 事件
if (eventType === ConversationEventTypeEnum.PROCESSING) {
const processingResult = responseData.result || {}
responseData.executeId = processingResult.executeId
// 生成包含容器块的 Markdown
const accumulatedText = getCustomBlock(
currentMessage.text || '',
responseData
)
// 更新消息
newMessage = {
...currentMessage,
text: accumulatedText,
status: MessageStatusEnum.Loading,
processingList: [
...(currentMessage?.processingList || []),
responseData,
] as ProcessingInfo[],
}
}
```
#### 场景 2检查标签是否已存在
`getCustomBlock` 函数已经内置了重复检查逻辑:
```uts
// 在 containerHelper.uts 中
export function getCustomBlock(
beforeText: string,
toolCallData: { type?: string; name?: string; executeId?: string; status?: string }
): string {
const { type, executeId } = toolCallData
if (!type || !executeId) {
return beforeText
}
const hasBlock = beforeText.includes(`executeId="${executeId}"`)
if (hasBlock) {
// 如果已经存在相同的执行ID则不重复添加
return beforeText
}
// ... 生成新块
}
```
#### 场景 3动态更新处理状态
```uts
// 当处理状态更新时,更新 processingList
const updateProcessingStatus = (
executeId: string,
newStatus: ProcessingEnum,
result?: ExecuteResultInfo
) => {
const currentMessage = getCurrentMessage()
// 更新 processingList 中对应项的状态
const updatedProcessingList = currentMessage.processingList?.map(item => {
if (item.executeId === executeId) {
return {
...item,
status: newStatus,
result: result || item.result
}
}
return item
}) || []
// 更新消息
updateMessage({
processingList: updatedProcessingList
})
}
```
### 注意事项
1. **使用换行分隔**:容器块前后需要添加换行,确保 Markdown 解析正确
```markdown
✅ 正确
文本内容
:::container executeId="xxx"
:::
继续内容
❌ 可能有问题
文本内容
:::container executeId="xxx"
:::
继续内容
```
2. **属性值转义**:属性值中的引号需要转义或使用单引号
```markdown
✅ 正确
:::container name="页面\"预览\""
:::
✅ 也可以
:::container name='页面"预览"'
:::
```
3. **避免重复插入**:在流式渲染中,注意检查标签是否已存在,避免重复插入
4. **executeId 唯一性**:确保每个容器块都有唯一的 `executeId`,用于标识和匹配 `processingList` 中的数据
## 实现步骤:创建自定义渲染组件
如果你需要创建新的自定义渲染组件,请按照以下步骤操作。
### 第一步:了解容器组件结构
容器组件位于 `uni_modules/mp-html/components/mp-html/container/container.vue`。
**组件 Props**
```vue
props: {
data: {
type: Object,
required: true
}
}
```
**数据合并逻辑**
在 `node.vue` 中,通过 `getRenderData()` 方法合并数据:
```javascript
getRenderData(data) {
if(!data) {
return {}
}
if(typeof data === 'string') {
data = JSON.parse(data)
}
// 从 processingList 中按优先级获取数据并合并
const result = {
...data,
...getProcessingDataByPriority(data.executeId, this.processingList)
}
return result
}
```
### 第二步:修改容器组件(如果需要)
如果需要支持新的渲染类型,可以修改 `container.vue` 组件:
```vue
<template>
<view class="tool-call-status">
<!-- 根据 type 渲染不同的内容 -->
<view v-if="toolCall.type === 'Page'" class="page-preview">
<!-- 页面预览内容 -->
</view>
<view v-else-if="toolCall.type === 'Tool'" class="tool-call">
<!-- 工具调用内容 -->
</view>
<!-- 默认渲染 -->
<view v-else class="default-container">
<!-- 默认内容 -->
</view>
</view>
</template>
<script>
export default {
props: {
data: {
type: Object,
required: true
}
},
computed: {
toolCall() {
return this.data
}
}
}
</script>
```
### 第三步:在 Markdown 中使用
在代码中生成包含容器块的 Markdown
```uts
import { getCustomBlock } from '@/subpackages/utils/containerHelper.uts'
const markdown = getCustomBlock('', {
executeId: 'exec-123',
type: 'Page',
name: '页面预览',
status: 'EXECUTING'
})
```
### 第四步:传递 processingList
在消息对象中设置 `processingList`
```uts
const message = {
text: markdown,
processingList: [
{
executeId: 'exec-123',
type: AgentComponentTypeEnum.Page,
name: '页面预览',
status: ProcessingEnum.EXECUTING,
result: {
input: { /* 输入参数 */ },
data: { /* 输出数据 */ }
},
cardBindConfig: { /* 卡片配置 */ },
targetId: 123
}
] as ProcessingInfo[]
}
```
### 第五步:在组件中使用
在消息组件中传递 `processingList`
```vue
<template>
<mp-html
:content="processedText"
:processing-list="msg.processingList"
:markdown="true"
:container-style="`width: 100%;`"
/>
</template>
```
## 现有示例
### 示例 1工具调用容器Tool Call
**生成 Markdown**
```uts
import { getCustomBlock } from '@/subpackages/utils/containerHelper.uts'
const markdown = getCustomBlock('', {
executeId: 'tool-call-123',
type: 'Tool',
name: '读取文件',
status: 'EXECUTING'
})
```
**Markdown 语法**
```markdown
:::container executeId="tool-call-123" type="Tool" status="EXECUTING" name="读取文件"
:::
```
**渲染效果**
- 显示工具名称和状态
- 支持展开/收起查看详情
- 显示调用参数和结果
- 支持复制功能
### 示例 2页面预览容器Page Preview
**生成 Markdown**
```uts
const markdown = getCustomBlock('', {
executeId: 'page-preview-123',
type: 'Page',
name: '页面预览',
status: 'FINISHED'
})
```
**processingList 数据**
```uts
{
executeId: 'page-preview-123',
type: AgentComponentTypeEnum.Page,
name: '页面预览',
status: ProcessingEnum.FINISHED,
result: {
input: { url: 'https://example.com' },
data: { html: '<div>页面内容</div>' }
}
}
```
**渲染效果**
- 显示页面名称和状态
- 支持预览页面(点击眼睛图标)
- 显示执行结果
### 示例 3动态状态更新
**初始状态**
```uts
// 生成 EXECUTING 状态的容器
let markdown = getCustomBlock('', {
executeId: 'exec-123',
type: 'Page',
status: 'EXECUTING'
})
let processingList = [{
executeId: 'exec-123',
status: ProcessingEnum.EXECUTING,
// ... 其他字段
}]
```
**更新为完成状态**
```uts
// 更新 processingList
processingList = processingList.map(item => {
if (item.executeId === 'exec-123') {
return {
...item,
status: ProcessingEnum.FINISHED,
result: {
input: { /* ... */ },
data: { /* ... */ }
}
}
}
return item
})
// Markdown 文本不需要修改,组件会自动根据 processingList 更新显示
```
## 最佳实践
### 1. 使用工具函数生成容器块
**推荐**:使用 `getCustomBlock` 函数
```uts
import { getCustomBlock } from '@/subpackages/utils/containerHelper.uts'
const markdown = getCustomBlock(beforeText, toolCallData)
```
**原因**
- 自动处理重复检查
- 统一的格式和风格
- 易于维护和修改
### 2. executeId 唯一性
**推荐**:确保每个容器块都有唯一的 `executeId`
```uts
const executeId = `exec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
```
**原因**
- 用于匹配 `processingList` 中的数据
- 避免重复插入容器块
- 支持动态更新状态
### 3. 数据分离
**推荐**:简单属性放在容器语法中,复杂数据通过 `processingList` 传递
```uts
// ✅ 推荐
// Markdown 中只包含标识信息
:::container executeId="exec-123" type="Page" status="EXECUTING"
:::
// processingList 中包含完整数据
processingList: [{
executeId: 'exec-123',
result: { /* 复杂数据 */ }
}]
```
**原因**
- Markdown 文本保持简洁
- 复杂数据可以动态更新
- 支持流式渲染
### 4. 状态管理
**推荐**:使用 `ProcessingEnum` 枚举管理状态
```uts
import { ProcessingEnum } from '@/types/enums/common.uts'
const status = ProcessingEnum.EXECUTING // 'EXECUTING'
// 或
const status = ProcessingEnum.FINISHED // 'FINISHED'
const status = ProcessingEnum.FAILED // 'FAILED'
```
**原因**
- 类型安全
- 避免拼写错误
- 便于维护
### 5. 优先级处理
**推荐**:理解 `getProcessingDataByPriority` 的优先级逻辑
优先级顺序:`FINISHED` > `FAILED` > `EXECUTING`
```uts
// 如果 processingList 中有多个相同 executeId 的项
// 会优先使用 FINISHED 状态的数据
const result = getProcessingDataByPriority(executeId, processingList)
```
**原因**
- 确保显示最终状态
- 支持状态覆盖
- 处理并发更新
### 6. 错误处理
**推荐**:在生成容器块时进行数据验证
```uts
export function getCustomBlock(
beforeText: string,
toolCallData: { type?: string; name?: string; executeId?: string; status?: string }
): string {
const { type, executeId } = toolCallData
// ✅ 验证必要字段
if (!type || !executeId) {
console.warn('getCustomBlock: type 或 executeId 缺失', toolCallData)
return beforeText
}
// ... 其他逻辑
}
```
### 7. 性能优化
**推荐**:避免频繁更新 Markdown 文本
```uts
// ✅ 推荐:只更新 processingList
updateMessage({
processingList: newProcessingList
})
// ❌ 不推荐:每次都重新生成整个 Markdown
updateMessage({
text: generateNewMarkdown() // 可能导致性能问题
})
```
**原因**
- Markdown 解析有性能开销
- `processingList` 更新更高效
- 组件会自动响应数据变化
## 常见问题
### Q1: 容器块不显示怎么办?
**A**: 检查以下几点:
1. **Markdown 语法是否正确**:确保使用 `:::container` 语法,且前后有换行
```markdown
✅ 正确
:::container executeId="xxx"
:::
❌ 错误
:::container executeId="xxx":::
```
2. **mp-html 组件配置**:确保 `markdown` 属性为 `true`
```vue
<mp-html :markdown="true" :content="text" />
```
3. **容器插件是否启用**:检查 `markdown-it/index.js` 中是否配置了 `markdown-it-container`
4. **控制台错误**:检查浏览器控制台是否有错误信息
### Q2: 如何传递复杂数据?
**A**: 使用 `processingList` 传递复杂数据:
```uts
// 在消息对象中设置
const message = {
text: markdownWithContainer,
processingList: [{
executeId: 'exec-123',
result: {
input: { /* 复杂对象 */ },
data: { /* 复杂对象 */ }
}
}]
}
```
容器组件会自动从 `processingList` 中获取并合并数据。
### Q3: 如何更新容器状态?
**A**: 更新 `processingList` 中对应项的状态:
```uts
// 更新 processingList
const updatedList = processingList.map(item => {
if (item.executeId === targetExecuteId) {
return {
...item,
status: ProcessingEnum.FINISHED,
result: newResult
}
}
return item
})
// 更新消息
updateMessage({ processingList: updatedList })
```
容器组件会自动响应数据变化并更新显示。
### Q4: 如何支持新的容器类型?
**A**: 修改 `container.vue` 组件,添加新的渲染逻辑:
```vue
<template>
<view class="custom-container">
<!-- 根据 type 渲染不同内容 -->
<view v-if="toolCall.type === 'YourNewType'">
<!-- 新类型的渲染内容 -->
</view>
<!-- 现有类型 -->
<view v-else>
<!-- 现有渲染逻辑 -->
</view>
</view>
</template>
```
### Q5: 容器组件如何获取数据?
**A**: 数据通过以下流程传递:
1. **Markdown 中的属性**:通过 `:::container executeId="xxx"` 传递
2. **processingList**:通过组件 props 传递
3. **数据合并**:在 `node.vue` 的 `getRenderData()` 中合并
4. **组件接收**`container.vue` 通过 `data` prop 接收合并后的数据
### Q6: 如何调试容器组件?
**A**: 使用以下方法:
1. **添加 console.log**:在 `container.vue` 中打印接收的数据
```vue
<script>
export default {
props: {
data: Object
},
mounted() {
console.log('Container data:', this.data)
}
}
</script>
```
2. **检查 processingList**:确认 `processingList` 中是否有对应 `executeId` 的数据
3. **检查数据合并**:在 `node.vue` 的 `getRenderData()` 中添加日志
4. **Vue DevTools**:使用 Vue DevTools 检查组件 props 和 data
### Q7: 如何处理多个相同 executeId 的容器?
**A**: `getProcessingDataByPriority` 函数会按优先级返回数据:
```uts
// 优先级FINISHED > FAILED > EXECUTING
const result = getProcessingDataByPriority(executeId, processingList)
// 返回优先级最高的项
```
如果有多个相同 `executeId` 的项,会优先使用 `FINISHED` 状态的数据。
### Q8: 容器组件支持哪些交互?
**A**: 当前 `container.vue` 组件支持:
- **展开/收起**:点击头部切换详情显示
- **复制**:复制参数或结果到剪贴板
- **预览**:对于 Page 类型,支持页面预览(点击眼睛图标)
可以在 `container.vue` 中添加更多交互功能。
### Q9: 如何在流式渲染中避免重复插入?
**A**: `getCustomBlock` 函数已经内置了重复检查:
```uts
const hasBlock = beforeText.includes(`executeId="${executeId}"`)
if (hasBlock) {
return beforeText // 已存在,不重复添加
}
```
确保每次调用时传入完整的 `beforeText`。
### Q10: 容器组件样式如何自定义?
**A**: 修改 `container.vue` 中的样式:
```vue
<style lang="scss">
.tool-call-status {
// 自定义样式
background-color: #f0f0f0;
border-radius: 8rpx;
// 更多样式...
}
</style>
```
## 总结
通过本指南,你可以:
1. ✅ 理解 Markdown 自定义渲染的架构和原理
2.**快速上手**:学会如何在代码中生成包含自定义容器标签的 Markdown
3.**深入实现**:了解如何创建和自定义渲染组件
4. ✅ 遵循最佳实践,编写高质量的代码
5. ✅ 解决常见问题,快速定位和修复错误
## 参考资源
- [mp-html 文档](https://github.com/jin-yufeng/mp-html)
- [markdown-it-container 文档](https://github.com/markdown-it/markdown-it-container)
- [uni-app-x 官方文档](https://uniapp.dcloud.net.cn/uni-app-x/)
---
**维护者**:开发团队
如有问题或建议,请联系开发团队或提交 Issue。