最近要做一个类似 DeepSeek 聊天框回复效果的 Agent 聊天界面,包含 markdown 流数据渲染,实现打印机效果。
实现打印机效果
原版是另一个大佬写的,因为实现完这个功能很久才写的这篇文章,已经找不到原贴了。 由于原版的结束队列不满足我这边的要求,所以 done 方法改了一下,自动等打印完再结束队列,且加了个回调函数。
主要解决问题:接口流数据返回的时间间隔短,可能1s内就返回了十几条数据,调用 done 的时机是 onclose,在流数据1s返回了所有数据并走进了 onclose 的情况下,原版 done 是直接结束了打字机队列消费并且一次性输出全部内容,这个时候页面上就会一下子闪现出全部内容,没有时间来实现打印机效果渲染。
js
// 打字机队列
export class Typewriter {
private queue: string[] = []
private consuming = false
private timer: ReturnType<typeof setTimeout> | null = null
private doneTimer: ReturnType<typeof setTimeout> | null = null
constructor(private onConsume: (str: string) => void, public callBack?: () => void) {}
// 输出速度动态控制
adjustSpeed() {
const TIME_ELAPSED = 2000
const MAX_SPEED = 200
const speed = TIME_ELAPSED / this.queue.length
if (speed > MAX_SPEED) {
return MAX_SPEED
}
else {
return speed
}
}
// 添加字符串到队列
add(str: string) {
if (!str)
return
str = str.replaceAll('\\n', '\n')
this.queue.push(...str.split(''))
}
// 消费
consume() {
if (this.queue.length > 0) {
const str = this.queue.shift()
str && this.onConsume(str)
}
}
// 消费下一个
next() {
this.consume()
// 根据队列中字符的数量来设置消耗每一帧的速度,用定时器消耗
this.timer = setTimeout(() => {
this.consume()
if (this.consuming) {
this.next()
}
}, this.adjustSpeed())
}
// 开始消费队列
start() {
this.consuming = true
this.next()
}
// 自动等打印完再结束消费队列,且有传回调函数的话执行回调函数
done() {
if (this.queue.length === 0) {
clearTimeout(this.doneTimer as ReturnType<typeof setTimeout>)
clearTimeout(this.timer as ReturnType<typeof setTimeout>)
this.consuming = false
this.callBack?.()
}
else {
this.doneTimer = setTimeout(() => {
this.done()
}, 1000)
}
}
}
SSE请求
这里用了 fetch-event-source,安装后引入
npm install @microsoft/fetch-event-source
js
import { fetchEventSource } from '@microsoft/fetch-event-source'
function StreamRequest(path: string, options: any, { onopen, onmessage, onclose }: any) {
return new Promise((resolve, reject) => {
fetchEventSource(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
...options,
async onopen(response) {
onopen && onopen(response)
},
onmessage(msg) {
onmessage && onmessage(msg)
},
onclose() {
onclose && onclose()
resolve('close')
},
onerror(err) {
reject(err)
throw new Error(err || 'onerror')
},
openWhenHidden: true, // 解决切页后重发
})
})
}
块渲染
由于打印机效果渲染 HTML 时如果直接刷新整个 innerHTML ,会导致前面已经渲染出来的节点操作会被后渲染的节点刷新影响,所以这里要实现动态更新 markdown。
js
/** 核心函数, 对比节点的内容 实现动态更新 markdown 的 div 而不是用 innerHTML 的属性全部刷新 */
export function updateDOMNode(oldNode: HTMLElement, newNode: HTMLElement) {
// 递归比较更新新、旧节点的子节点
function _diffAndUpdate(before: HTMLElement, after: HTMLElement) {
// 情况 1:更新文本内容
if (
before
&& before.nodeType === Node.TEXT_NODE
&& after.nodeType === Node.TEXT_NODE
) {
if (before.nodeValue !== after.nodeValue) {
before.nodeValue = after.nodeValue
}
return
}
// 情况 2:新旧节点标签名不同,替换整个节点
if (!before || before.tagName !== after.tagName) {
// 克隆新节点
const newNode = after.cloneNode(true)
if (before) {
// 替换旧节点
before?.parentNode?.replaceChild(newNode, before)
}
else {
// 若不存在旧节点,直接新增
after?.parentNode?.appendChild(newNode)
}
return
}
// 遍历新节点的子节点,逐个与旧节点的对应子节点比较
afterChildren.forEach((afterChild, index) => {
const beforeChild = beforeChildren[index]
if (!beforeChild) {
// 若旧节点的子节点不存在,直接克隆新节点的子节点并添加到 before
const newChild = afterChild.cloneNode(true)
before.appendChild(newChild)
}
else {
// 若旧节点的子节点存在,递归比较和更新
_diffAndUpdate(beforeChild as HTMLElement, afterChild as HTMLElement)
}
})
// 删除旧节点中多余的子节点
if (beforeChildren.length > afterChildren.length) {
for (let i = afterChildren.length; i < beforeChildren.length; i++) {
before.removeChild(beforeChildren[i])
}
}
}
// 从根开始
_diffAndUpdate(oldNode, newNode)
}
markdown-it + 代码高亮 + 代码收缩
npm install markdown-it
npm install highlight.js
npm install markdown-it-collapsible
js
import MarkdownIt from 'markdown-it'
import MarkdownItCollapsible from 'markdown-it-collapsible'
import hljs from 'highlight.js'
import 'highlight.js/styles/base16/material-palenight.css'
import 'highlight.js/styles/base16/material-palenight.min.css'
const md: MarkdownIt = MarkdownIt({
html: true,
linkify: true,
breaks: true,
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre><code class="hljs">${
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
}</code></pre>`
}
catch (__) {}
}
return `<pre><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
},
}).use(MarkdownItCollapsible)
调用流数据接口,解析数据
js
const typewriter = new Typewriter((str: string) => {
streamingText.value += str
if (markdownRef.value) {
const tmpDiv = document.createElement('div')
tmpDiv.innerHTML = md.render(streamingText.value) // 只渲染当前的块
removeCursor(markdownRef.value)
updateDOMNode(markdownRef.value, tmpDiv)
scrollToBottom(markdownRef)
}
}, () => {
streaming.value = false
})
streamRequest('xxx', { input: prompt }, { method: 'GET' }, {
/* 请求打开 */
onopen() {
typewriter.start() // 开始打字
},
/* 收到消息 */
onmessage(message) {
if (message.data) {
const dataObj = JSON.parse(message.data)
dataObj?.content && typewriter.add(dataObj.content)
}
},
onclose() {
typewriter.done()
removeCursor(markdownRef.value)
},
onerror() {
removeCursor(markdownRef.value)
},
})