一、對話內容渲染
在前端頁面的 AI 對話場景中,對話內容的渲染效果直接影響用戶的閱讀體驗和交互效率。合理選擇對話格式、優化流式對話呈現、嵌入自定義內容以及實現語音播報等功能,是提升整體體驗的關鍵。
對話格式選擇
MarkDown
- 作為一種輕量級標記語言,語法簡潔易懂,能快速實現文本的加粗、斜體、列表、鏈接等格式渲染。在 AI 對話中,若回復內容以文字為主,且需要簡單的排版區分(如強調重點信息、羅列步驟等),MarkDown 是不錯的選擇。通過輕量的前端插件支持即可實現渲染,對移動端 H5 的性能影響較小,適合追求輕量化的場景
渲染插件-常規選擇
- 當對話內容包含更豐富的樣式,如復雜的表格、代碼塊、圖片混排等,常規的 MarkDown 可能無法滿足需求,此時可選擇專業的渲染插件。例如,markdown-it、showdown.js、react-markdown、vue-markdown等等,能靈活配置渲染規則,支持自定義標簽。這類插件兼容性較好,能適配多數移動瀏覽器,適合對對話樣式有一定要求的場景。
渲染插件-編輯能力
- 若業務場景要求用戶能編輯 AI 生成的對話內容(如修改文本、調整格式后保存或分享),則需要選擇具備編輯能力的渲染插件。
- TinyMCE是一款功能強大的富文本編輯器,支持實時預覽、格式刷、表格編輯等功能,且能與 AI 對話接口無縫集成,實現 “生成 - 編輯 - 提交” 的閉環。
- CKEditor提供了豐富的插件生態,可根據需求擴展圖片上傳、代碼編輯等功能,適合對編輯體驗要求較高的場景。
- lexical 是一個功能強大的JavaScript庫,專為構建富文本編輯器而設計。它采用獨特的架構,允許開發者以靈活的方式創建和定制文本編輯體驗。可以用于處理用戶輸入的文本,對其進行語法分析、語義理解預處理等操作,同時支持流暢的文本顯示與交互,為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', // 也可以是Postheaders: {'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().widthif (!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 domif (!renderMap[echartId]) {dom = createEchartDom(echartId, option);} else {dom = renderMap[echartId].dom}return (<divstyle={{width: '100%',height: `${300}px`,}}ref={(node) => node?.appendChild(dom)}/>);} catch {return (// 圖表規則解析失敗<divstyle={{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 (<Markdowncomponents={{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為文本轉音頻請求方法 返回promiseconst [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 = 0let 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 = 0setIsPlaying(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 = curReadUrlsetIsLoading(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.currentif (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()
場景擴展
- 如何重新生成回答?