IT评测·应用市场-qidao123.com
标题:
webscoket 渲染AI返回流式 markdown 格式数据
[打印本页]
作者:
干翻全岛蛙蛙
时间:
2025-3-12 13:28
标题:
webscoket 渲染AI返回流式 markdown 格式数据
背景
去年的时候做了一个对接ai 模块的业务,当时因为那个模块说的返回的都是文章纯文本,所以没有分析markdown,今年来了就弄一个PC H5的ai版本 本文章重要就写 写这次ai的心路进程。
重要就三个问题
1. 对话框
2. websocket 发消息/接消息
3. markdown 的渲染
OK 开始
重要是写在vue里面的 去年的是写在blade模板里面的,但是思路都大致差不多
页面结构这个样子
问答完这样
和市面上的AI 问答基本上差不多(测试环境 请忽略思考用时)
渲染地区分为思考的地区 和 正式回答的地区,因为之前的时候是在php里面写的blade模板,所以利用的innerHtml 现在是在vue里面所以利用v-html 关于这俩后面还有个问题,后面再提,
基础页面都很好处理 重要就是textarea 的问题 下面是处理好的textarea结构
<div :class="['footer', focusText ? 'borderi' : '']">
<div class="chat-input">
<span>{{ message }}</span>
<textarea
ref="textarea"
v-model="message"
inputmode="text"
placeholder="试试输入关键词引导AI生成点评 shift + enter 换行"
rows="1"
@keydown.enter.exact.prevent="sendMessage"
@keydown.shift.enter.prevent="insertLineBreak"
@blur="blurTeaxarea"
@focus="focusTeaxarea"
/>
<div class="uplaod" @click="sendMessage">
<img
:style="{ cursor: message ? 'pointer' : 'not-allowed' }"
:src="
message
? require('@/assets/images/ai-submit-active.png')
: require('@/assets/images/ai-submit.png')
"
alt=""
>
</div>
</div>
</div>
复制代码
js部门
sendMessage() {
if (this.isComposing) return // 中文输入法组合期间不发送
if (this.message == '' && !this.fileList.length) {
this.$message.warning('请输入后提问')
return
}
// 判断wss 的连接状态
this.$refs.textarea.blur()
this.focusText = false
this.state = 2
this.startChat()
},
insertLineBreak(event) {
if (this.message.trim() == '') {
this.$message.warning('请输入后再换行')
event && event.preventDefault()
return
}
document.execCommand('insertLineBreak') // 更好的换行兼容性
this.updateMessage()
},
this.$refs.textarea.addEventListener(
'compositionstart',
this.handleComposition
)
this.$refs.textarea.addEventListener(
'compositionend',
this.handleComposition
)
// 中文输入法状态处理
handleComposition(e) {
this.isComposing = e.type !== 'compositionend'
},
复制代码
insertLineBreak 重要是处理huan
css样式 原来UI图是渐变的边框但是和border-radius 冲突了,这里就不写怎么实现的了
.footer {
margin: 10px 0;
border: 1px solid #f0f1f4;
border-radius: 10px;
overflow: hidden;
padding: 6px 10px;
.chat-input {
position: relative;
display: flex;
align-items: flex-end;
background: #ffffff;
max-height: 200px;
span {
display: block;
min-height: 42px;
white-space: pre-wrap;
word-wrap: break-word;
visibility: hidden;
padding: 10px;
width: calc(100% - 62px);
box-sizing: border-box;
font-size: 14px;
line-height: 1.5;
}
textarea {
font-size: 14px;
width: calc(100% - 62px);
background: #ffffff;
padding: 10px;
border: none;
box-sizing: border-box;
resize: none;
overflow-y: auto;
scrollbar-width: none; /* 隐藏滚动条(Firefox) */
-ms-overflow-style: none; /* 隐藏滚动条(IE 10+) */
resize: none;
height: 100%;
position: absolute;
left: 0;
top: 0;
line-height: 1.5;
&::-webkit-scrollbar {
display: none;
}
}
textarea::placeholder {
color: #999;
text-align: left;
// text-align: center; /* 水平居中 */
vertical-align: middle; /* 垂直居中 */
}
.uplaod {
// width: 42px;
height: 42px;
z-index: 1;
background: #fff;
display: flex;
align-items: center;
cursor: pointer;
}
img {
width: 60px;
height: 36px;
}
}
}
.borderi {
border-color: #ff9327;
// border-image: linear-gradient(0deg, #FF9327 0%, #FF5627 100%) 1;
border-radius: 10px;
// border-radius: 20px;
// background-clip: padding-box, border-box;
// background-origin: padding-box, border-box;
// background-image: linear-gradient(to right, #222, #222),
// linear-gradient(90deg, #8f41e9, #578aef);
}
复制代码
关于websocket ,当初想的是想用sse的但是 小程序不支持sse所以后面都选websocket了,
initWebsocket(msg) {
// 初始化 WebSocket 连接
this.websocket = new WebSocket(process.env.VUE_APP_WEBSOCKETBASE_URL)
// 连接成功建立的回调方法
const cb = () => {
this.websocket.send(JSON.stringify(msg))
}
this.websocket.addEventListener('open', () => {
let content
if (this.sessionId) {
const obj = {}
obj.sessionId = this.sessionId
obj.token = getToken()
content = obj
} else {
content = {
sessionId: null,
token:getToken()
}
}
const msgToken = { type: 'auth', content: content }
this.websocket.send(JSON.stringify(msgToken))
this.callbackWebSocket(cb)
})
},
callbackWebSocket(cb) {
// 收到消息事件
this.websocket.onmessage = (msg) => {
// 如果返回对象里面有结束标志的话 就把对象存起来
const res = JSON.parse(msg.data)
// console.log("msg", msg);
if (res.type == 'connected') {
// 开启会话,返回websocketid
if (cb) {
cb()
}
this.qaData[this.indexUse].socketId = res.content
this.socketId = res.content
//
} else if (res.type == 'new_session') {
// 创建会话,返回会话id、title
//
// if (res.content.length > 19) {
const content = JSON.parse(res.content)
this.qaData[this.indexUse].sessionId = content.sessionId
this.sessionId = content.sessionId
this.qaData[this.indexUse].id = content.id
// }
} else if (res.type == 'finish' || res.type == 'stop') {
// 有可能超时
this.$set(this.qaData[this.indexUse].data.answer, 'id', res.content)
// 停止或回答完
this.doingAnswer = false
this.$set(this.qaData[this.indexUse].data.answer, 'status', 2)
const loading = document.querySelector('#loading')
if (loading && loading.parentNode) {
loading.parentNode.removeChild(loading)
}
this.scrollTop = false
this.scrolltoBottom()
} else if (res.type == 'answer') {
// 答案
if (res.thinking == 1) {
this.$set(this.qaData[this.indexUse].data.answer, 'thinkFlag', true)
// 计算时间
this.thinkingTime = +new Date()
this.thinking = true
this.$forceUpdate()
} else if (res.thinking == 3) {
// 计算时间
const thinkingTime = Math.ceil(
(+new Date() - this.thinkingTime) / 1000
)
this.$set(
this.qaData[this.indexUse].data.answer,
'thinkingTime',
thinkingTime
)
this.thinking = false
console.log(2)
} else if (res.thinking == 2) {
// 正在思考中
if (!this.qaData[this.indexUse].data) return
if (res.content.length) {
const thinkContent =
this.qaData[this.indexUse].data.answer.thinkContent || ''
this.$set(
this.qaData[this.indexUse].data.answer,
'thinkContentCopy',
thinkContent + res.content
)
this.$set(
this.qaData[this.indexUse].data.answer,
'thinkContent',
thinkContent + res.content
)
this.$forceUpdate()
this.scrolltoBottom()
}
} else {
if (!res.content) return
const answerCopy =
this.qaData[this.indexUse].data.answer.answerCopy || ''
this.$set(
this.qaData[this.indexUse].data.answer,
'answerCopy',
answerCopy + res.content
)
this.streamBuffer += res.content + ''
const elImg = ''
this.rawMarkdown += this.streamBuffer
console.log('this.rawMarkdown', this.rawMarkdown)
const mdSring = this.md.render(this.rawMarkdown + elImg)
this.$set(this.qaData[this.indexUse].data.answer, 'answer', mdSring)
this.$forceUpdate()
this.streamBuffer = ''
// 需要the-main 滚动到底部
console.log(4)
this.scrolltoBottom()
}
} else if (res.type == 'error') {
console.log('error', res, this.indexUse)
this.doingAnswer = false
this.thinking = false
} else if (res.type == 'timeout') {
// 超时
const answer = this.qaData[this.indexUse].data.answer.answer || ''
this.$set(
this.qaData[this.indexUse].data.answer,
'answer',
answer + res.content
)
this.$set(this.qaData[this.indexUse].data.answer, 'status', 3)
this.$refs['a' + this.indexUse][0].innerHTML = res.content || ''
this.doingAnswer = false
this.streamBuffer = ''
this.rawMarkdown = ''
this.scrolltoBottom()
}
}
// 连接关闭事件
this.websocket.onclose = function () {
console.log('Socket已关闭')
this.doingAnswer = false
}
// 发生了错误事件
this.websocket.onerror = function () {
// alert('Socket发生了错误')
}
},
startChat() {
const msg = { type: 'chat', content: q }
if (typeof WebSocket == 'undefined') {
console.log('遗憾:您的浏览器不支持WebSocket')
this.$message.warning('遗憾:您的浏览器不支持WebSocket')
} else {
if (this.websocket && this.websocket.readyState == 1) {
// 已经连接 了
console.log('===', msg)
this.websocket.send(JSON.stringify(msg))
} else {
this.initWebsocket(msg)
}
}
}
复制代码
如果有seesionId的话 就可以继承毗连websockt利用,如果状态不是毗连状态的话 就去重新毗连,对返回的数据类型 举行归纳重新渲染数据 ,我是把他存在了一个qaData里面 ,qaData的数据结构如下
this.qaData[index + 1] = {
socketId: '',
sessionId: item.sessionId,
id: item.id,
listIndex: index + 1,
data: {
question: item.question,
imageList,
answer: {
id: item.answerList && item.answerList[0].id,
status: item.status,
answer:
item.answerList && this.md.render(item.answerList[0].answer),
answerCopy: item.answerList && item.answerList[0].answer,
thinkContent: item.answerList && item.answerList[0].think,
thinkContentCopy: item.answerList && item.answerList[0].think,
thumbsUp: item.answerList ? item.answerList[0].thumbsUp : null,
thinkingTime:
item.answerList &&
item.answerList[0].thinkTime &&
Math.ceil(item.answerList[0].thinkTime / 1000),
thinkFlag: item.answerList && item.answerList[0].thinkTime,
},
},
复制代码
这样可以兼顾回显,新建和回显都用同一套数据结构,比较方便
第三点markdown的渲染
先说上面innerHtml和v-html 因为最开始的时候是利用的innerHtml 然后这个版本是利用的v-html,最开始写的时候 我没有直接改变v-html 绑定的值而是利用innerHtml改变的,然后就出现了渲染中如果往上滚动的话 markdown格式就是丢失,我查了之后才发现 如果改变v-html 的innerHtml的话 就会有渲染问题,
我利用了markdown-it 插件 但是 直接引入有问题,这个项目没支持.mjs 的引入,所以利用dist/index.js 或者直接把min.js 复制一份放到项目标静态资源里面 ,我看了两种方式的打包后的结果巨细基本都是一样的。
在利用Markdown-it之前先要做些设置特殊处理一些 样式,例如表格,毗连。。。
initMd() {
this.md = markdownit()
// 自定义代码块渲染器
this.md.renderer.rules.code_block = function (
tokens,
idx,
options,
env,
self
) {
const token = tokens[idx]
const content = token.content
const lang = token.info ? token.info.trim() : ''
return `<div class="code-block"><pre><code class="language-${lang}" style="white-space: pre-wrap;">${content}</code></pre></div>`
}
this.md.renderer.rules.table_open = function () {
return '<div class="table-wrapper" style="overflow:auto;"><table border>'
}
this.md.renderer.rules.table_close = function () {
return '</table></div>'
}
this.md.renderer.rules.link_open = function (
tokens,
idx,
options,
env,
self
) {
// 获取链接的 href 和 title 属性
const hrefIndex = tokens[idx].attrIndex('href')
const href = hrefIndex > -1 ? tokens[idx].attrs[hrefIndex][1] : null
const titleIndex = tokens[idx].attrIndex('title')
const title = titleIndex > -1 ? tokens[idx].attrs[titleIndex][1] : null
// 返回一个禁止点击的链接
return `<a href="${href}" class="no-href" data-link="a" title="${title}">`
}
this.md.renderer.rules.image = function (
tokens,
idx,
options,
env,
self
) {
const token = tokens[idx]
const aIndex = token.attrIndex('src')
if (token.content == 'diy-loading-img') {
return `<img
id="loading"
src="/img/loading.gif"
style="width:20px;height:20px;vertical-align: middle;margin:0;"
/>`
} else {
return `<img src="${token.attrs[aIndex][1]}" alt="${token.content}" />`
}
}
}
复制代码
天生表格的时候外面加了一层 div 防止表格过于宽 ,链接 加了一个禁止点击 根据data-link="a" 事件委托举行处理
然后每次返回的数据 拼接渲染就行了
this.streamBuffer += res.content + ''
const elImg = ''
this.rawMarkdown += this.streamBuffer
console.log('this.rawMarkdown', this.rawMarkdown)
const mdSring = this.md.render(this.rawMarkdown + elImg)
this.$set(this.qaData[this.indexUse].data.answer, 'answer', mdSring)
this.$forceUpdate()
复制代码
elImg 是每次文本后面的loading,this.forceUpdate() 因为是深层的对象,所以须要利用 逼迫渲染,大概就是这些,当然 还有更细节的处理方式
下面是一个对markdown流的处理
// import MarkdownIt from 'markdown-it/dist/markdown-it.js'
export class MarkdownStateMachine {
constructor() {
this.state = {
inCodeBlock: false, // 是否在代码块内
codeBlockLanguage: '', // 代码块语言
inList: false, // 是否在列表项中
listDepth: 0, // 列表嵌套深度
inBlockquote: false, // 是否在引用块中
tableStage: 0, // 表格解析阶段 (0: 未开始, 1: 表头, 2: 分隔线, 3: 表体)
table: {
stage: 0,
header: [],
body: [],
buffer: []
},
activeBlock: null,
};
this.buffer = ''; // 原始未处理文本
this.openBlocks = []; // 未闭合块堆栈
this.renderedHtml = ''; // 已安全渲染的HTML
}
getMd(md) {
this.md = md
}
// 更新状态机
updateState(chunk) {
const lines = chunk.split('\n');
lines.forEach(line => {
if (this.state.inCodeBlock) {
if (line.startsWith('```')) this.state.inCodeBlock = false;
return; // 代码块内的内容不触发其他状态变化
}
if (line.startsWith('```')) {
this.state.inCodeBlock = true;
// this.state.codeBlockLanguage = line.replace('```', '').trim();
// 处理代码块
} else if (/^(\*|\-|\+)\s/.test(line)) {
this.state.inList = true;
this.state.listDepth = line.match(/^\s*/)[0].length / 2; // 假设缩进为2空格
} else if (line.startsWith('> ')) {
this.state.inBlockquote = true;
} else if (line.includes('|') && line.includes('-')) {
this.state.tableStage = this.state.tableStage < 2 ? 2 : 3;
this.state.table.stage = this.state.tableStage
}
});
}
// 判断当前缓冲区是否可安全渲染
isSafeToRender(buffer) {
// if (this.state.inCodeBlock) return false;
// if (this.state.tableStage % 2 !== 0) return false; // 表格需要成对渲染
return true;
}
}
复制代码
isSafeToRender() 可以判断 现在正在举行的是什么表格 或者 代码块啦,这样 就可以根据具体的需求来处理,例如,天生表格的时候加一个处理进度什么之类的。
大概就是这些,每年都有提升,还不错,加油!
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
欢迎光临 IT评测·应用市场-qidao123.com (https://dis.qidao123.com/)
Powered by Discuz! X3.4