一、对话内容渲染
在前端页面的 AI 对话场景中,对话内容的渲染效果直接影响用户的阅读体验和交互效率。合理选择对话格式、优化流式对话呈现、嵌入自定义内容以及实现语音播报等功能,是提升整体体验的关键。
对话格式选择
MarkDown
- 作为一种轻量级标记语言,语法简洁易懂,能快速实现文本的加粗、斜体、列表、链接等格式渲染。在 AI 对话中,若回复内容以文字为主,且需要简单的排版区分(如强调重点信息、罗列步骤等),MarkDown 是不错的选择。通过轻量的前端插件支持即可实现渲染,对移动端 H5 的性能影响较小,适合追求轻量化的场景
渲染插件-常规选择
- 当对话内容包含更丰富的样式,如复杂的表格、代码块、图片混排等,常规的 MarkDown 可能无法满足需求,此时可选择专业的渲染插件。例如,markdown-it、showdown.js、react-markdown、vue-markdown等等,能灵活配置渲染规则,支持自定义标签。这类插件兼容性较好,能适配多数移动浏览器,适合对对话样式有一定要求的场景。
渲染插件-编辑能力
- 若业务场景要求用户能编辑 AI 生成的对话内容(如修改文本、调整格式后保存或分享),则需要选择具备编辑能力的渲染插件。
扩展场景
- 如何对ai回答的内容实现编辑功能?
- 如何对ai回答中的片段实现再润色功能?
流式对话
流式对话能让 AI 的回复内容逐字或逐句呈现,减少用户等待感,提升交互流畅度。实现流式对话一般使用 SSE(Server-Sent Events)与后端建立长连接,后端在生成内容的过程中持续向前端推送数据。前端接收到数据后,需实时更新对话界面。
一般有两种方式可以发起SSE长连接请求
使用EventSource对象
EventSource是浏览器原生支持的 SSE API,使用简单且兼容性良好。示例代码如下:
const eventSource = new EventSource('/your-api-url');
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
// 处理后端推送的数据,更新对话界面
// ......
};
eventSource.onerror = function(error) {
console.error('SSE连接错误:', error);
eventSource.close();
};
使用EventSource时,默认会自动重连,适合对连接稳定性要求高的场景。但它仅支持 HTTP/HTTPS 协议,且无法自定义请求头,以及传递复杂的请求参数。
使用fetch API 模拟 SSE
通过fetch发起 GET 请求,并手动处理响应流,可实现类似 SSE 的效果。此方式更灵活,能自定义请求方法、请求头,但需要开发者自行处理连接管理和错误重连逻辑:
async function createSSEStream() {
const response = await fetch('/your-api-url', {
method: 'GET', // 也可以是Post
headers: {
'Content-Type': 'text/event-stream',
// 可添加自定义请求头参数
}
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
// while响应流式数据
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const lines = text.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = JSON.parse(line.slice(5).trim());
// 处理后端推送的数据,更新对话界面
// ......
}
}
}
}
使用fetch模拟 SSE 时,可根据业务需求灵活配置请求参数,例如添加身份验证信息、调整请求超时时间等,适用于对请求定制化要求较高的复杂场景。
场景扩展
- 如何中断长连接
对话嵌入自定义内容-echarts
在 AI 对话中嵌入图表(如数据可视化、趋势分析图)能让信息更直观易懂,ECharts 是实现这一功能的常用工具。
echarts提示词
- 前端需要向 AI 传递明确的提示信息,说明需要生成图表的类型(如折线图、柱状图)、数据维度(如时间、数值)、标题等内容。例如,提示词可以是 “请根据用户问题,返回图表数据,x 轴为日期,y 轴为活跃人数,标题为‘xxxx表’,数据格式为echarts折线图的option, 以JSON 格式输出”。
渲染echarts
- 一般将echarts提示词调整稳定以后,需要结合你选择的md渲染插件,定义一套规则来渲染echarts,推荐使用规则:```echart {option} ```,以下有两个实践中的实例
- markdown-it(自定义代码块规则)
// <template>
// <div
// class="message-wrap"
// v-html="renderedContent"
// />
// </template>
import { nextTick, ref, watchEffect } from 'vue'
import Markdown from 'markdown-it'
import type { Options } from 'markdown-it'
import highlight from 'highlight.js'
import * as echarts from 'echarts'
const props = defineProps<Props>()
const renderedContent = ref('')
const echartMap = ref(new Map())
const echartRendered = ref<string[]>([])
const mdOptions: Options = {
linkify: true,
typographer: true,
breaks: true,
langPrefix: 'language-',
// 代码高亮
highlight(str, lang) {
if (lang && highlight.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' + highlight.highlight(lang, str, true).value + '</code></pre>'
} catch (__) {}
}
return ''
},
}
const md = new Markdown(mdOptions)
const defaultRender = md.renderer.rules.fence
md.renderer.rules.think = (tokens, idx) => {
return `<div class="think-block">${md.utils.escapeHtml(tokens[idx].content)}</div>`
}
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx]
if (token.info.trim() === 'echart') {
const chartId = `chart-${props?.id}-${idx}`
try {
if (!echartMap.value.has(chartId)) {
const option = JSON.parse(token.content)
console.log('解析图表配置成功')
echartMap.value.set(chartId, option)
}
return `<div id='${chartId}' style='width:calc(100vw - 12px * 2 - 16px * 2);height:calc((100vw - 12px * 2 - 16px * 2) * 0.8)'></div>`
} catch (error) {
console.error('解析图表配置失败', token.content, error)
return `<div id='${chartId}' class='chart-loading' style='width:calc(100vw - 12px * 2 - 16px * 2);height:calc((100vw - 12px * 2 - 16px * 2) * 0.8)' >图表加载中...</div>`
}
}
return defaultRender(tokens, idx, options, env, self)
}
// 添加think标签渲染规则
watchEffect(() => {
renderedContent.value = md.render(props?.content)
nextTick(() => {
// 等待 DOM 更新后初始化图表
echartMap.value.forEach((option, id) => {
const container = document.getElementById(id)
const width = container?.getBoundingClientRect().width
if (!container) {
return
}
const chart = echarts.init(container, '', {
height: width * 0.8,
devicePixelRatio: 2,
})
echartRendered.value.push(id)
chart.setOption({ ...option, animation: false, animationDurationUpdate: 0 })
})
})
})
- react-markdown自定义代码块规则
const MarkdownContent = () => {
const resize = useRef();
const [resizeMap, setResizeMap] = useState([]); // 多图表尺寸动态优化
useEffect(() => {
if (!resize.current) {
resize.current = () => {
resizeMap.forEach((resize) => resize());
};
window.addEventListener('resize', resize.current);
}
return () => {
if (resize.current) {
window.removeEventListener('resize', resize.current);
resize.current = null;
}
};
}, [resizeMap]);
const [renderMap, setRenderMap] = useState({}); // 解决图表频繁闪动、以及只有第一个图表生效的问题
useEffect(() => {
const map = [];
Object.keys(renderMap).forEach((echartId) => {
const item = renderMap[echartId];
if (!item.rendered) {
const echart = echarts.init(item.dom, '', {
devicePixelRatio: 2,
height: 300,
width,
});
echart.setOption(item.option);
item.rendered = true;
map.push(echart.resize);
}
});
setResizeMap([...resizeMap, ...map]);
}, [renderMap]);
const createEchartDom = (echartId, option) => {
const dom = document.createElement('div');
dom.id = echartId;
dom.style.width = '100%';
dom.style.maxWidth = '100%';
dom.style.height = '300px';
// 解决图表频繁闪动、以及只有第一个图表生效的问题
setRenderMap({
...renderMap,
[echartId]: {
dom,
option,
rendered: false,
},
});
return dom;
};
const codeRender = (props: any) => {
const { children, className, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
if (match && match[1] === 'echart') {
try {
const startLine = props.node.position.start.line;
const echartId = `${id}-${startLine}`;
const option = {
...JSON.parse(children),
animation: false,
animationDurationUpdate: 0,
};
let dom
if (!renderMap[echartId]) {
dom = createEchartDom(echartId, option);
} else {
dom = renderMap[echartId].dom
}
return (
<div
style={{
width: '100%',
height: `${300}px`,
}}
ref={(node) => node?.appendChild(dom)}
/>
);
} catch {
return (
// 图表规则解析失败
<div
style={{
width: '100%',
height: `${300}px`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
border: '1px solid #dcdfe6',
borderRadius: 8,
background: '#f5f7fa',
}}
>
图表加载中...
</div>
);
}
}
return <code {...rest} className={className}>
{children}
</code>
};
return (
<Markdown
components={
{
code: (props: any) => codeRender(props, width),
}
}
>
{content}
</Markdown>
);
};
流式图表渲染
- 上面两步实现了在ai对话中实现图表渲染的能力,但是通常情况下对话过程是流式,会出现以下几个问题:
- 图表规则解析失败:通常发生在 option 处于正在生成中的情况下,解决办法就是加一个图表加载样式
- 图表会频繁闪动:通常发送在 option 已经生成完成,但是回答仍然在生成中的情况下,原因markdown渲染插件会生成新的echart容器,频繁卸载挂载dom元素,解决办法将已经生成好的option 和其绑定的图表容器缓存起来,每次markdown渲染复用图表容器
- 只有第一个图表生效:通常发送在一个会话或一个回答中出现多个图表的情况下,解决办法就是配合生成独立的id,并配合缓存渲染图表
场景扩展
- 如何实现ai回答中的渲染思考内容?
语音播报
语音播报能让用户在不便查看屏幕时获取对话内容,提升使用场景的灵活性。
长文本低延迟播报优化
- 对于长文本的语音播报,需解决延迟和卡顿问题。可采用分段播报策略,将长文本按标点符号或固定长度分割成多个片段,并发加载文本的语音数据,同时使用 audio 逐段播报,减少等待时间。另外,可根据网络状况动态调整分段长度,在网络较差时减小片段长度,避免因数据传输延迟导致播报中断。还可以通过缓存已播报过的文本语音,当用户重复收听时直接调用缓存,提升响应速度
- 参考方法:
/** 语音播放 */
export const useSpeech = content => {
const audioDom = document.createElement('audio')
const audioRef = useRef(audioDom)
const { read } = useSpeechWithSse() // read为文本转音频请求方法 返回promise
const [isPlaying, setIsPlaying] = useState(false)
const [playEnded, setPlayEnded] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [reedList, setReedList] = useState([])
const [readIndex, setReadIndex] = useState(0)
const pause = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause()
setIsPlaying(false)
setPlayEnded(true)
setIsLoading(false)
setReedList([])
}
}, [])
const speech = useCallback(async () => {
setReadIndex(0)
try {
setIsLoading(true)
// 分割成多个片段
let strIndex = 0
let curText = ''
let textlist = []
while (strIndex < content.length) {
curText = `${curText}${content[strIndex]}`
if (
[',', '。', ';', '?', ',', '!'].includes(content[strIndex]) ||
curText.endsWith('\n')
) {
const formatedText = curText.replace(/[\n#*-]+/g, '').trim()
if (formatedText) {
textlist.push(formatedText)
curText = ''
}
}
strIndex++
}
if (curText) {
textlist.push(curText)
curText = ''
}
strIndex = 0
setIsPlaying(true)
// 对文本片段的请求并发做出限制,500毫秒添加一个请求
const tempReedList = []
const timer = setInterval(() => {
if (textlist.length === 0) {
clearInterval(timer)
} else {
const nextText = textlist.shift()
tempReedList.push(read({ text: nextText }))
setReedList(tempReedList)
}
}, 500)
} catch (error) {
console.error('播放失败:', error)
} finally {
// setIsLoading(false);
}
}, [read, content, setIsLoading, audioRef])
const handleRead = useCallback(() => {
if (isPlaying) {
pause()
} else {
speech()
}
}, [isPlaying, speech, pause])
// 监听reedList 依次播报语音片段
useEffect(() => {
const loadRead = async () => {
if (playEnded && isPlaying && reedList.length > readIndex) {
setPlayEnded(false)
const curReadUrl = await reedList[readIndex]
setReadIndex(readIndex + 1)
if (curReadUrl && audioRef.current) {
audioRef.current.src = curReadUrl
setIsLoading(false)
}
}
if (reedList.length > 0 && reedList.length === readIndex) {
setPlayEnded(true)
setIsPlaying(false)
setReadIndex(0)
setReedList([])
}
}
loadRead()
}, [playEnded, isPlaying, reedList])
useEffect(() => {
// 处理音频播放结束
const handleEnded = () => {
setPlayEnded(true)
}
const handleOnload = () => {
setPlayEnded(false)
audioRef.current?.play()
}
// 处理音频播放错误
const handleError = e => {
console.error('音频播放错误:', e)
setPlayEnded(true)
setIsPlaying(false)
setIsLoading(false)
}
const audio = audioRef.current
if (audio) {
audio.addEventListener('ended', handleEnded)
audio.addEventListener('error', handleError)
audio.addEventListener('loadeddata', handleOnload)
}
return () => {
if (audio) {
audio.removeEventListener('ended', handleEnded)
audio.removeEventListener('error', handleError)
audio.removeEventListener('loadeddata', handleOnload)
URL.revokeObjectURL(audio.src)
}
}
}, [])
return { handleRead, isPlaying, isLoading }
}
场景扩展
- 如何实现回答边生成边播报语音?
二、对话输入框
对话输入框作为用户与 AI 交互的核心入口,在移动端 H5
页面设计中需兼顾便捷性与高效性。从交互设计角度,通过语音输入与文字输入双模式,降低用户输入成本,提升交互效率。在视觉层面,可以将输入的部分文本定制化样式功能,例如提供预设选项,优化用户输入体验。
富文本格式输入
集成富文本编辑器,支持用户输入加粗、斜体、列表、链接等多样化格式内容。在 AI 对话场景中,用户可通过富文本输入详细描述需求,AI 根据格式化后的文本进行更精准的理解与回复,提升交互效率。
- 以下是通过lexical 富文本插件实现的效果:

具有定制样式、交互功能的文本
在ai对话中,输入框内支持预设的问题模板(定制的文本节点样式与交互)是提升用户输入体验的关键。
- 以下是通过lexical 富文本插件实现的效果:

隐藏提示词
高程度定制化的ai对话应用,往往需要给用户输入问题补充提示词,同时要避免提示词干扰用户体验,所以需要隐藏提示词
- 可以将提示词以 CSS 样式方式隐藏;
- 也可以在用户触发对话时,通过 JavaScript 动态将提示词注入拼接;
语音识别
语音识别功能极大提升了 H5 页面的交互便捷性,尤其契合移动端用户碎片化、多场景的使用需求。在复杂的移动网络环境与多变的用户输入场景下,通过多重技术优化实现高效交互
快速识别
- 借助高性能语音识别 API,实现毫秒级响应。通过优化网络请求与数据处理流程,减少语音数据上传、识别及结果返回的耗时,让用户感受 “即说即现” 的流畅体验。
场景扩展
- 如何做到逐字、词识别?
停止生成
在 AI 生成内容的过程中,当用户发现 AI 输出内容与预期不符、生成方向偏离主题,或临时调整创作思路时,可通过点击 “停止生成” 按钮。点中断AI 生成进程,避免无效内容的持续输出。同时,系统会保留已生成的内容片段,用户可基于此进行修改、补充或重新发起生成指令,实现高效且个性化的内容创作体验。
eventSource (sse) 停止链接
- 可以通过调用 EventSource 实例的close()方法来停止与服务器的连接,不再接收新的事件流数据。
const eventSource = new EventSource('your_sse_url')
// 需要停止链接时执行
eventSource.close()
// 果希望在关闭后进行一些清理操作,可以通过监听close事件实现
eventSource.addEventListener('close', function() { console.log('SSE connection closed'); })
fetch (sse) 停止链接
- 使用
<font style="color:rgb(0, 0, 0);">fetch</font>模拟 SSE 时,通常是通过不断轮询获取数据来模拟流式传输。要停止链接,在<font style="color:rgb(0, 0, 0);">fetch</font>请求中传入<font style="color:rgb(0, 0, 0);">signal</font>参数来终止请求
const controller = new AbortController();
const signal = controller.signal;
fetch('your_url', { signal }).then(response => response.text()).then(data => console.log(data))
// 需要停止时调用, 请求将被立即终止,并抛出AbortError异常
controller.abort()
场景扩展
- 如何重新生成回答?
1485

被折叠的 条评论
为什么被折叠?



