背景
去年的时候做了一个对接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企服之家,中国第一个企服评测及商务社交产业平台。 |