一、整体思路流程
二、组件解读
1.sendSSe(重要): 用于发送 Server-Sent Events (SSE) 请求,并处理相关的回调
1. 导出 controller 变量
- controller 是一个 AbortController 的实例,用于控制请求的中止。在函数外部声明 controller 变量允许它在函数之外也能访问到。
2. sendSSe 函数定义
- 参数:
- bodyObj:请求体,可以是一个 JSON 字符串。
- onopen:一个函数,当请求成功并建立连接时被调用。
- onmessage:一个函数,用于处理接收到的每条消息。
- onclose:一个函数,当连接关闭时被调用。
- onerror:一个函数,用于处理请求错误。
- 功能:
- 创建 AbortController 实例:controller 是一个 AbortController 对象,用于能够中止请求。如果不再需要请求,可以调用 controller.abort() 来中止它。
- 解析请求体:尝试将 bodyObj 解析为 JSON 对象。如果解析失败(例如 bodyObj 不是有效的 JSON 字符串),则将 message 设置为空对象。
- 调用 fetchSource 函数:使用 bodyObj 和回调函数(onopen、onmessage、onclose、onerror)调用 fetchSource 函数。fetchSource 函数的实现不在这段代码中,但它应该负责实际的 SSE 请求处理。
注意事项
- 错误处理:sendSSe 函数尝试解析 bodyObj 为 JSON 对象,但如果失败,则不会引发异常,而是将 message 设为空对象。你可能需要根据实际需求来调整错误处理逻辑。
- fetchSource 函数:这段代码调用了 fetchSource 函数,但没有提供 fetchSource 的实现。fetchSource 应该是处理 SSE 连接的核心函数,包括请求的发送、消息的接收等逻辑。
let controller;
export function sendSSe(bodyObj, {
onopen,
onmessage,
onclose,
onerror
}) {
controller = new AbortController()
let message = {}
try {
message = JSON.parse(bodyObj)
} catch (e) {
message = {}
}
fetchSource(bodyObj, onopen, onmessage, onclose, onerror)
}
function fetchSource (bodyObj, onopen, onmessage, onclose, onerror) {
// const { rsid = '', rcuuid = '', rcver = '' } = message
fetchEventSource('https://ai-standard.umetrip.com/umeai-talkdirector-service/LlmController/callModel', {
headers: {
'Content-type': 'application/json'
},
body: bodyObj,
method: 'POST',
signal: controller.signal,
onopen,
onmessage,
onclose,
onerror
})
}
2. fetchEventSource: 处理服务器发送的事件(SSE),具备重试机制和资源管理功能
1. 设置请求头和重试逻辑:
- 复制传入的请求头,并检查是否存在 Accept 头,如果不存在,则设置为 SSE 所需的值(EventStreamContentType)。
- 初始化用于 fetch 操作和重试的变量。
2. 资源清理函数:
- dispose 函数用于清理资源,它会中止当前的请求并清除重试定时器。
3. 处理中止信号:
- 监听传入的中止信号 inputSignal。如果请求被中止,调用 dispose 函数并解析 promise,不再继续构造或记录错误。
4. 发起请求和处理响应:
- 使用 fetch 函数发起请求,传入必要的参数和请求体。请求体通过 JSON.stringify 转换为 JSON 字符串。
- 调用 onopen 回调函数处理响应。
- 通过 getBytes 和 getMessages 处理响应体中的数据,并调用 onmessage 处理每条消息。
- 如果收到的消息包含 ID,将其存储在请求头中,并在下次重试时发送。
- 处理完成后,调用 onclose 回调函数,清理资源并解析 promise。
5. 错误处理和重试机制:
- 如果请求过程中发生错误,记录错误信息并尝试重试。
- 使用 setTimeout 设置重试延迟,并在每次重试时增加重试计数,最多重试 maxRetry 次。
- 如果超过最大重试次数仍然失败,清理资源并拒绝 promise。
关键参数说明:
- input:请求的 URL 或配置对象。
- inputSignal:可选的中止信号。
- inputHeaders:请求头。
- inputBody:请求体,通常是 JSON 格式。
- inputOnOpen:请求成功后的回调函数。
- onmessage:处理接收到的每条消息的回调函数。
- onclose:请求完成后的回调函数。
- onerror:处理请求错误的回调函数。
- inputFetch:自定义的 fetch 实现(如果没有提供,则使用默认的 window.fetch)。
- ...rest:其他额外的 fetch 配置参数。
这个函数设计用于处理 SSE 连接,具备重试机制,能够在网络错误或服务器问题时自动重试请求,并确保在请求完成后正确清理资源。
3. getMessages: 处理从流式数据中接收到的消息。这种流式数据通常用于 Server-Sent Events (SSE) 的上下文
解析和处理服务器发送的事件流消息。它通过 onLine 函数将每一行数据分解为字段和值,并将这些数据传递给适当的回调函数。每当遇到空行时,它将当前消息传递给 onMessage 回调,并重置消息对象以接收新消息。这种模式在处理服务器推送的实时数据(如 SSE)时非常有效。
function getMessages(onId, onRetry, onMessage) {
let message = newMessage();
const decoder = new TextDecoder();
return function onLine(line, fieldLength) {
// Function body...
}
}
-
getMessages: 这个函数接受三个回调函数作为参数:onId: 当接收到id字段时调用。onRetry: 当接收到retry字段时调用。onMessage: 当消息结束时调用。
-
message: 用于存储当前消息的对象。newMessage是一个函数,应该返回一个新的空消息对象。 -
decoder:TextDecoder实例用于解码字节数组(Uint8Array)为字符串。通常用于处理二进制数据流中的文本。
onLine 函数
getMessages 函数返回的 onLine 函数用于处理每一行数据。它接受两个参数:
line: 当前行的字节数组。fieldLength: 行中字段部分的长度。
处理逻辑
- 空行处理
javascriptCopy Code
if (line.length === 0) {
onMessage && onMessage(message);
message = newMessage();
}
- 处理字段和值
else if (fieldLength > 0) {
const field = decoder.decode(line.subarray(0, fieldLength));
const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
const value = decoder.decode(line.subarray(valueOffset));
console.log('data', field, value)
switch (field) {
case 'data':
// if this message already has data, append the new value to the old.
// otherwise, just set to the new value:
message.data = message.data
? `${message.data}\n${value}`
: value; // otherwise,
break;
case 'event':
message.event = value;
break;
case 'id':
onId(message.id = value);
break;
case 'retry':
// eslint-disable-next-line no-case-declarations
const retry = parseInt(value, 10);
if (!isNaN(retry)) { // per spec, ignore non-integers
onRetry(message.retry = retry);
}
break;
default:
break;
}
}
export const closeSSE = () => {
if (controller) {
// console.log('enter 断开链接::::')
controller.abort()
controller = undefined
}
}
5,请求调用
try {
sendSSe(JSON.stringify(item), {
onopen: () => {
console.log('enter on open');
},
onmessage: (event) => {
const answer = toJsonParse(event.data, 'object');
this.answer = answer;
this.$set(this.messageData[this.robotIndex], 'resume', '...');
this.$set(this.messageData[this.robotIndex], 'msg', '<p>...</p>');
// 定时器清除
this.handleAnswerTimerClear('中台返回');
this.handleModelAnswerWithMsgType();
},
onclose: (event) => {
console.log('enter onclose::::', event);
},
onerror: () => {
console.log('enter on error::::');
}
});
} catch (error) {
console.log('流式异常')
this.streamFlag = false
this.$set(this.messageData[this.robotIndex], 'resume', '网络异常');
this.$set(this.messageData[this.robotIndex], 'msg', '<p>网络异常</p>');
console.log('异常报错', error);
}
6.打字机效果
要实现打字机效果的流式输出,可以采用以下思路:
- 逐字符输出:模拟打字机效果,通过逐个字符的方式输出文本。
- 定时器控制:使用
setInterval或requestAnimationFrame定期更新显示的字符,实现逐字显示的效果。 - 缓存文本:将待显示的完整文本缓存到变量中,然后逐步取出并显示每个字符。
function typewriterEffect(text, element, interval = 100) {
let index = 0;
const timer = setInterval(() => {
if (index < text.length) {
element.textContent += text[index++];
} else {
clearInterval(timer);
}
}, interval);
}
// 使用示例const container = document.getElementById('output');
typewriterEffect('Hello, world!', container);
text:待显示的文本。
element:显示文本的 HTML 元素。
interval:每个字符之间的时间间隔(毫秒)。
7.代码
sseUtils.js
const moment = require('moment');
// 4.断开 FETCH-SSE 连接
export const closeSSE = () => {
if (controller) {
// console.log('enter 断开链接::::')
controller.abort()
controller = undefined
}
}
function newMessage() {
// data, event, and id must be initialized to empty strings:
// https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
// retry should be initialized to undefined so we return a consistent shape
// to the js engine all the time: https://mathiasbynens.be/notes/shapes-ics#takeaways
return {
data: '',
event: '',
id: '',
retry: undefined
};
}
function concat(a, b) {
const res = new Uint8Array(a.length + b.length);
res.set(a);
res.set(b, a.length);
return res;
}
async function getBytes(stream, onChunk) {
const reader = stream.getReader();
let result;
// eslint-disable-next-line no-cond-assign, no-await-in-loop
while (!(result = await reader.read()).done) {
onChunk(result.value);
}
}
const ControlChars = {
NewLine: 10,
CarriageReturn: 13,
Space: 32,
Colon: 58
}
function getLines(onLine) {
let buffer;
let position; // current read position
let fieldLength; // length of the `field` portion of the line
let discardTrailingNewline = false;
// return a function that can process each incoming byte chunk:
return function onChunk (arr) {
if (buffer === undefined) {
buffer = arr;
position = 0;
fieldLength = -1;
} else {
// we're still parsing the old line. Append the new bytes into buffer:
buffer = concat(buffer, arr);
}
const bufLength = buffer.length;
let lineStart = 0; // index where the current line starts
while (position < bufLength) {
if (discardTrailingNewline) {
if (buffer[position] === ControlChars.NewLine) {
lineStart = ++position; // skip to next char
}
discardTrailingNewline = false;
}
// start looking forward till the end of line:
let lineEnd = -1; // index of the \r or \n char
for (; position < bufLength && lineEnd === -1; ++position) {
switch (buffer[position]) {
case ControlChars.Colon:
if (fieldLength === -1) { // first colon in line
fieldLength = position - lineStart;
}
break;
// \r case below should fallthrough to \n:
case ControlChars.CarriageReturn:
discardTrailingNewline = true;
break;
case ControlChars.NewLine:
lineEnd = position;
break;
default:
break;
}
}
if (lineEnd === -1) {
// We reached the end of the buffer but the line hasn't ended.
// Wait for the next arr and then continue parsing:
break;
}
// we've reached the line end, send it out:
onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
lineStart = position; // we're now on the next line
fieldLength = -1;
}
if (lineStart === bufLength) {
buffer = undefined; // we've finished reading it
} else if (lineStart !== 0) {
// Create a new view into buffer beginning at lineStart so we don't
// need to copy over the previous lines when we get the new arr:
buffer = buffer.subarray(lineStart);
position -= lineStart;
}
}
}
// 3.处理从流式数据中接收到的消息
// onId: 当接收到 id 字段时调用。
// onRetry: 当接收到 retry 字段时调用。
// onMessage: 当消息结束时调用。
function getMessages(
onId,
onRetry,
onMessage
) {
let message = newMessage(); // newMessage 是一个函数,应该返回一个新的空消息对象。
const decoder = new TextDecoder();
// getMessages 函数返回的 onLine 函数用于处理每一行数据
return function onLine (line, fieldLength) {
// 空行处理
if (line.length === 0) {
onMessage && onMessage(message);
message = newMessage();
} else if (fieldLength > 0) { // exclude comments and lines with no values
// line is of format "<field>:<value>" or "<field>: <value>"
// https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
const field = decoder.decode(line.subarray(0, fieldLength));
const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
const value = decoder.decode(line.subarray(valueOffset));
console.log('data', field, value)
switch (field) {
case 'data':
// if this message already has data, append the new value to the old.
// otherwise, just set to the new value:
message.data = message.data
? `${message.data}\n${value}`
: value; // otherwise,
break;
case 'event':
message.event = value;
break;
case 'id':
onId(message.id = value);
break;
case 'retry':
// eslint-disable-next-line no-case-declarations
const retry = parseInt(value, 10);
if (!isNaN(retry)) { // per spec, ignore non-integers
onRetry(message.retry = retry);
}
break;
default:
break;
}
}
}
}
// 2.处理服务器发送的事件(SSE),具备重试机制和资源管理功能
const EventStreamContentType = 'text/event-stream';
const LastEventId = 'last-event-id';
function fetchEventSource(input, {
signal: inputSignal,
headers: inputHeaders,
body: inputBody,
onopen: inputOnOpen,
onmessage,
onclose,
onerror,
fetch: inputFetch,
...rest
}) {
return new Promise((resolve, reject) => {
// make a copy of the input headers since we may modify it below:
const headers = { ...inputHeaders };
if (!headers.accept) {
headers.accept = EventStreamContentType;
}
let curRequestController;
// let retryInterval = DefaultRetryInterval;
const retryTimer = 0;
function dispose() {
// document.removeEventListener('visibilitychange', onVisibilityChange);
window.clearTimeout(retryTimer);
curRequestController.abort();
}
// if the incoming signal aborts, dispose resources and resolve:
inputSignal && inputSignal.addEventListener('abort', () => {
// console.log('enter abort::::::')
dispose();
resolve(); // don't waste time constructing/logging errors
});
const fetch = inputFetch || window.fetch;
const onopen = inputOnOpen
let retryCount = 0;
const maxRetry = 3; // 设置重试次数
async function create() {
curRequestController = new AbortController();
try {
let params = JSON.parse(inputBody)
params.dateTime = moment(new Date()).format('YYYY-MM-DD HH:mm:ss');
const response = await fetch(input, {
...rest,
headers,
body: JSON.stringify(params),
signal: curRequestController.signal
});
await onopen(response);
await getBytes(response.body, getLines(getMessages((id) => {
if (id) {
// store the id and send it back on the next retry:
headers[LastEventId] = id;
} else {
// don't send the last-event-id header anymore:
delete headers[LastEventId];
}
}, () => {
// retryInterval = retry;
}, onmessage)));
onclose && onclose();
dispose();
resolve();
} catch (err) {
console.log('里层抛出异常了', err)
// 防止重刷太快 时分秒是一样的问题
setTimeout(() => {
if (retryCount < maxRetry) {
retryCount++
console.log(`重刷第${retryCount} 次`)
create()
} else {
console.error('多次重刷后仍失败', err)
dispose();
reject(err);
}
}, 1000)
// if (!curRequestController.signal.aborted) {
// // if we haven't aborted the request ourselves:
// try {
// // check if we need to retry:
// const interval = onerror?.(err) ?? retryInterval;
// // console.log('interval::::::', interval)
// window.clearTimeout(retryTimer);
// retryTimer = window.setTimeout(create, interval);
// } catch (innerErr) {
// // we should not retry anymore:
// dispose();
// reject(innerErr);
// }
// }
}
}
create();
});
}
// 1.用于发送 Server-Sent Events (SSE) 请求,并处理相关的回调
let controller;
export function sendSSe(bodyObj, {
onopen,
onmessage,
onclose,
onerror
}) {
controller = new AbortController()
let message = {}
try {
message = JSON.parse(bodyObj)
} catch (e) {
message = {}
}
fetchSource(bodyObj, onopen, onmessage, onclose, onerror)
}
function fetchSource (bodyObj, onopen, onmessage, onclose, onerror) {
// const { rsid = '', rcuuid = '', rcver = '' } = message
fetchEventSource('https://ai-standard.umetrip.com/umeai-talkdirector-service/LlmController/callModel', {
headers: {
'Content-type': 'application/json'
},
body: bodyObj,
method: 'POST',
signal: controller.signal,
onopen,
onmessage,
onclose,
onerror
})
}
<style lang="less">
@import "../../assets/css/base.less";
</style>
<style scoped>
.main_wrapper {
background: #f5f6f7;
width: 7.5rem;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.mod2-text1 {
color: #383838;
}
.swipe-ul1 {
/* padding-left: 1rem; */
white-space: nowrap;
overflow-x: auto;
margin-bottom: 0.3rem;
}
.swipe-ul1 li {
display: inline-block;
padding-top: 0.16rem;
width: 1.57rem;
height: 1.32rem;
font-size: 0.24rem;
margin-right: 0.2rem;
border-radius: 0.07rem;
}
.swipe-ul1 .icon {
width: 0.59rem;
height: 0.59rem;
}
.mod1-text2 {
font-size: 0.26rem;
}
.tag {
font-size: 0.26rem;
}
.tag span {
padding: 0.1rem 0.17rem;
}
.mod2-icon1,
.mod2-icon2,
.mod2-icon3,
.mod2-dls dt {
width: 0.8rem;
height: 0.8rem;
}
.mod2-dls dd {
font-size: 0.26rem;
}
.mod2 textarea {
font-size: 0.24rem;
background: #fff;
}
.mod2 button {
font-size: 0.24rem;
width: 2rem;
height: 0.76rem;
}
.word-feed {
word-break: break-all;
}
.tipMod5 {
font-size: 20px;
}
.tipAP{
width: 6.90rem;
background: #ffffff;
border-radius: 0.08rem;
box-shadow: 0px 0px 0.12rem 0px rgba(0,0,0,0.07);
bottom: 1.46rem;
left: 0.3rem;
/* padding: 0.1rem 0.3rem; */
position: fixed;
}
.tipAP li{
padding: 0.1rem 0.15rem;
line-height: 0.45rem;
border-bottom: 1px solid rgba(237,237,237,1);
font-size: 16px;
}
.tipAP li:last-child{
border: none;
}
.mod1-text1 {
white-space: pre-line !important;
}
</style>
<template>
<div class="main_wrapper">
<div class="mod1" id="scroll">
<template v-for="item in data">
<!-- <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div> -->
<template v-if="item.event == 'autoServiceMessage'">
<div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
<ul class="swipe-ul1">
<li @click="asyncSendMessage('首乘关怀', '首乘关怀', $event)" class="li1">
<i class="icon icon1"></i>首乘关怀
</li>
<li
@click="asyncSendMessage('行程单查询', '行程单查询', $event)"
class="li2"
>
<i class="icon icon2"></i>行程单查询
</li>
<li @click="asyncSendMessage('值机服务', '值机服务', $event)" class="li3">
<i class="icon icon3"></i>值机服务
</li>
<li
@click="asyncSendMessage('非自愿改期', '非自愿改期', $event)"
class="li4"
>
<i class="icon icon4"></i>非自愿改期
</li>
</ul>
<div class="mod5" v-if="item.msg.labelList.length">
<ul class="mod5-ul1">
<li
@click="currentIndex = index2"
v-for="(item2, index2) in item.msg.labelList"
:key="index2"
:class="{ active: currentIndex == index2 }"
>
{{ item2.labelTitle }}
</li>
</ul>
<ul
v-for="(item2, index2) in item.msg.labelList"
:key="index2"
class="mod5-ul2"
v-if="currentIndex == index2"
>
<li
v-for="(item3, index3) in item2.questionAnswerList"
:key="index3"
@click="sendMessage(item3)"
v-if="item3.isUsed == '1'"
>
<template>
<div class="text1">{{ item3.question }}</div>
<van-icon name="arrow" />
</template>
</li>
</ul>
</div>
</template>
<template
v-else-if="
item.event == 'sendMessage' || item.event == 'sendNotReplyMessage'
"
>
<div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
<div class="mod1-r2" v-if="item.from.connType == connType">
<div class="mod1-r2c1">
<div class="mod2-text1 word-feed" v-html="item.msg"></div>
</div>
<div class="mod1-r2c2">
<img :src="item.from.connImg" alt="" />
</div>
</div>
<div class="mod1-r1" v-else>
<div class="mod1-r1c1">
<img :src="item.from.connImg" alt="" />
</div>
<div class="mod1-r1c2">
<div class="mod1-text1 word-feed" v-html="item.msg"></div>
</div>
</div>
</template>
<template v-else-if="item.event == 'evaluateMessage'">
<div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
<div class="tag">
<span>感谢您使用我们的服务,请为本次服务做出评价!</span>
</div>
<div class="mod2">
<div class="mod2-dls active">
<dl @click="comment(item, 5)">
<dt :class="{ active: item.evaluate == 5 }">
<div class="mod2-icon1"></div>
</dt>
<dd>满意</dd>
</dl>
<dl @click="comment(item, 3)">
<dt :class="{ active: item.evaluate == 3 }">
<div class="mod2-icon2"></div>
</dt>
<dd>一般</dd>
</dl>
<dl @click="comment(item, 1)">
<dt :class="{ active: item.evaluate == 1 }">
<div class="mod2-icon3"></div>
</dt>
<dd>不满意</dd>
</dl>
</div>
<template v-if="item.evaluate">
<div class="mod2-textarea">
<textarea
:disabled="item.disabled"
v-model="item.evaluateText"
placeholder="请输入评价信息"
></textarea>
</div>
<div class="mod2-button">
<button
:disabled="item.disabled"
@click="evaluateResponse(item)"
>
提交评价
</button>
</div>
</template>
</div>
</template>
<template v-else-if="item.event == 'lineUpMessage'">
<div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
<div class="mod1-text2" v-html="item.msg.lineUpTemple"></div>
</template>
<template v-else-if="item.event == 'robotMessage'">
<div>
<div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
<div class="mod1-r1">
<div class="mod1-r1c1">
<img :src="item.from.connImg" alt="" />
</div>
<div class="mod1-r1c2">
<div class="mod1-text1">
<div v-html="item.resume.replace(/无查询值也没航班号/g, '')"></div>
</div>
<div
class="mod5-content"
v-if="
item.candidateTemplates && item.candidateTemplates.length
"
>
<div
class="tipMod5"
style="
padding: 0.15rem 0;
border-bottom: 1px solid rgba(237, 237, 237, 1);
margin-right: -0.2rem;
"
>
或者您想了解以下内容:
</div>
<ul class="mod5-ul2">
<li
v-for="item2 in item.candidateTemplates"
@click="asyncSendMessage(item2, item2, $event)"
>
<div class="text1">{{ item2 }}</div>
<van-icon name="arrow" />
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="item.event == 'autoServiceAnswerMessage'">
<div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
<div class="mod1-r1">
<div class="mod1-r1c1">
<img :src="item.from.connImg" alt="" />
</div>
<div class="mod1-r1c2">
<div class="mod1-text1" v-html="item.msg.text"></div>
<a target="_blank" :href="item.msg.url" class="mod1-button">{{
item.msg.button
}}</a>
</div>
</div>
</template>
<template v-else>
<div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
<div class="mod1-r1">
<div class="mod1-r1c1">
<img :src="item.from.connImg" alt="" />
</div>
<div class="mod1-r1c2">
<div class="mod1-text1" v-html="item.resume"></div>
</div>
</div>
</template>
</template>
<div @click="closeTalkInfo()" v-if="talkStatus == 3" class="mod1-close">
<i></i>结束服务
</div>
</div>
<div>
<div class="tipAP">
<ul v-if="showTip && !isEnd">
<li @click="asyncSendMessage(item, item, $event)" v-for="(item, index) in tips" :key="index">
{{ item }}
</li>
</ul>
</div>
<div ref="bottomRef" id="scrollDiv"></div>
<div class="footer">
<div class="footer-inner">
<div class="footer-r1">
<div id="text-container" class="text"></div>
<div class="footer-r1c2">
<a
href="javascript:;"
class="footer-send"
@click="asyncSendMessage()"
></a>
<div @click="handleToolBar" class="icon"></div>
</div>
</div>
<div v-show="showToolbar" class="toolbar" id="toolbar-container"></div>
</div>
</div>
</div>
</div>
</template>
<script>
import echarts from 'echarts';
import { fetchPost, reqUpload } from '../../utils/web';
import { urlBase, uploadApi, getImgUrl } from '../../../config/config.js';
import {
getUrlParams,
toJsonParse,
splitString,
sleep,
debounce,
throttle
} from '../../utils/tools';
import E from 'wangeditor';
import moment from 'moment';
import { Toast } from 'vant';
import { sendSSe, closeSSE } from '../../utils/sseUtils';
import { setTimeout } from 'timers';
let answerTimer = null;
let scrollTimer = null;
export default {
data() {
return {
showToolbar: false,
chatWindow: '',
isRead: false,
data: [],
talkStatus: 0,
evaluate: '',
evaluateText: '',
currentIndex: 0,
tips: [],
enable: false,
showTip: false,
appId: '',
agentId: '',
answerText: '', // sse返回的所有文字答案
answer: {}, // 当前sse返回的答案
renderText: '', // 打字机效果渲染所用的答案
isRender: false, // 当前是否在渲染答案
isRenderAll: true, // 当前答案是否全部渲染完成
isLoading: false,
sessionId: '',
currentAnswerIndex: 0, // 答案字符串取值当前下标
isScrollEnd: false, // 是否已经滚动到页面最后
robotIndex: 0, // 机器人当前索引
messageData: [], // message的值
isEnd: false, // 是否结束
streamFlag: false, // 流文件全局 防止重复调用
timerId: ''
};
},
mounted() {
if (localStorage.getItem('connUid')) {
this.connUid = window.localStorage.getItem('connUid');
} else {
this.connUid = this.generateUUID();
window.localStorage.setItem('connUid', this.connUid);
}
this.connType = this.$route.query.connType; // 身份类型:0——游客,1——普通旅客,2——VIP旅客,3——客服,4——管理员
this.connName = this.$route.query.connName;
this.connImg = this.$route.query.connImg;
this.editor = this.initEditor();
this.getParamsUser();
this.memberOnline();
this.enable = false;
},
created() {},
methods: {
// 初始进入调用,会获取用户的设备信息等
memberOnline() {
fetchPost('umeai-chatonline/chatMember/memberOnline', {
event: 'memberOnline', // 事件类型:上线接口固定memberOnline
from: {
connUid: this.connUid, // 用户字符uid
connType: this.connType
}, // 发言人信息
msg: {
equipmentCode: this.connUid, // 设备号:前端生成随机字符串标识
ua: this.getBrowser(), // 浏览器信息
// ip: returnCitySN["cip"], //客户端IP地址
// ipCity: returnCitySN['cname'], //客户端IP所在城市
sysInfo: this.getOS(), // 操作系统
referren: location.href, // 入口URL
source: this.$route.query.source // 接入渠道,一般只有旅客才会使用: LOONGAIR_APP_GJ--APP、LOONGAIR_VX_GJ--微信公众号、LOONGAIR_WEB_GJ--网站、LOONGAIR_SINA_GJ--新浪、LOONGAIR_MINI_GJ--小程序
}, // 触发上线的客户端信息:业务中只有对旅客才会有记录对需求
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res) {
setInterval(() => {
this.polling();
}, 500);
} else {
Toast.fail('无数据');
}
});
},
// 短轮询的方式一直发起网络请求;
polling() {
fetchPost('umeai-chatonline/chatMember/polling', {
from: {
connUid: this.connUid, // 用户字符uid
connType: this.connType
}, // 发言人信息
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res.data) {
res.data.unreadMessage.forEach((item) => {
if (item.chatWindowType == 2) {
this.chatWindow = item.chatWindow;
this.talkStatus = item.talkStatus;
if (!this.isRead) {
this.readMessage(item, 'all');
} else {
if (item.unreadMessageCount > 0) {
this.readMessage(item, 'new');
} else {
this.isEnd = false;
// this.enable = false;
}
}
}
});
} else {
location.reload();
}
});
},
// 获取某条具体的消息
readMessage(item, type) {
fetchPost('umeai-chatonline/chatMember/readMessage', {
dateTime: moment(new Date()).format('YYYY-MM-DD hh:mm:ss'),
event: 'readMessage',
from: {
connUid: this.connUid, // 用户字符uid
connType: this.connType
}, // 发言人信息
to: item.chatWindow,
msg: {
firstSeq: type == 'new' ? this.data[this.data.length - 1].seq + 1 : 0,
lastSeq: type == 'new' ? '' : item.lastSeq,
firstTimeStamp: '',
lastTimeStamp: '',
queryText: ''
},
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res) {
this.isRead = true;
res.data.forEach((item, index) => {
if (item.event === 'evaluateMessage') {
this.checkEvaluation(item);
}
});
if (type === 'all') {
this.data = res.data;
} else {
if (
res.data.length > 0 &&
res.data[0].seq > this.data[this.data.length - 1].seq
) {
this.data = this.data.concat(res.data);
}
}
let mData = this.data;
for (let i = 0; i < mData.length; i++) {
if (mData[i].msg && mData[i].msg.lineUpTemple) {
let lineUpTemple = mData[i].msg.lineUpTemple;
if (lineUpTemple.indexOf('#') >= 0) {
let valueArr = lineUpTemple.split('#');
let value = mData[i].msg.value || 0;
mData[i].msg.lineUpTemple =
valueArr[0] +
`<strong>${value}</strong> ` +
valueArr[valueArr.length - 1];
}
}
}
this.data = mData;
// 流式数据
let robotMsg = this.data.some((item) => item.event == 'robotMessage');
if (robotMsg) {
this.messageData = this.data || [];
} else {
this.isEnd = false;
this.enable = false;
}
this.data.forEach((item, index) => {
if (item.event === 'robotMessage' && !this.handleIsString(item)) {
if (this.streamFlag) {
return;
}
if (!this.streamFlag) {
this.handleStream(item, index, type);
this.$set(this.messageData[index], 'candidateTemplates', []);
this.streamFlag = true
}
}
})
setTimeout(() => {
var div = document.querySelector('#scroll');
div.scrollTop = div.scrollHeight;
}, 0);
} else {
Toast.fail('无数据');
}
}).catch((e) => {
})
},
asyncSendMessage(msg, resume, event) {
this.showTip = false
if (this.enable) {
Toast.fail('请不要频繁点击!');
return
}
this.enable = true
if (this.isEnd) {
this.showTip = false
Toast.fail('请等待流文件输出完...');
return;
}
this.isEnd = true;
if (this.editor.txt.text() === '' && !resume && !msg) {
Toast.fail('请输入内容');
return;
}
throttle(this.debounceSendMessage(msg, resume), 10000)
// this.debounceSendMessage(msg, resume)
},
// 发送消息调用方法
debounceSendMessage(msg, resume) {
if (this.enable) {
this.isLoading = false;
this.isRenderAll = false;
this.streamFlag = false
// 将相关变量初始化
this.isRenderAll = true;
this.isLoading = true;
this.currentAnswerIndex = 0;
this.renderText = '';
this.answerText = '';
this.isRenderAll = false;
this.isRender = false;
let msgContent = resume || this.editor.txt.text();
if (msgContent === ' ' || msgContent === ' ') {
Toast.fail('请输入内容');
return;
}
fetchPost('umeai-chatonline/chatMember/asyncSendMessage', {
dateTime: moment(new Date()).format('YYYY-MM-DD HH:mm:ss'),
event: 'sendMessage',
from: {
connUid: this.connUid, // 用户字符uid
connType: this.connType,
connName: this.connName ? this.connName : undefined,
connImg: this.connImg ? this.connImg : undefined
}, // 发言人信息
to: this.chatWindow,
resume: resume || this.editor.txt.text(),
msg: msg || this.editor.txt.html(),
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res.data) {
this.editor.txt.html('');
// this.enable = false;
} else {
Toast.fail('无数据');
// this.enable = false;
}
}).catch(() => {
// this.enable = false;
})
}
},
// 发送消息方法
sendMessage(item, type) {
fetchPost('umeai-chatonline/chatMember/asyncSendMessage', {
dateTime: moment(new Date()).format('YYYY-MM-DD HH:mm:ss'),
event: 'sendNotReplyMessage',
from: {
connUid: type === 'robot' ? 'Robot' : this.connUid, // 用户字符uid
connType: type === 'robot' ? 3 : this.connType,
connName:
type !== 'robot' && this.connName ? this.connName : undefined,
connImg: type !== 'robot' && this.connImg ? this.connImg : undefined
}, // 发言人信息
to: this.chatWindow,
resume: type === 'robot' ? item.answer : item.question,
// msg: msg ? window.btoa(window.encodeURIComponent(msg)) : window.btoa(window.encodeURIComponent(this.editor.txt.html()))
msg: type === 'robot' ? item.answer : item.question,
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res.data) {
if (type !== 'robot') {
this.sendMessage(item, 'robot');
}
this.editor.txt.html('');
} else {
Toast.fail('无数据');
}
});
},
evaluateResponse(item) {
item.disabled = true;
fetchPost('umeai-talkdirector-service/talkInfo/evaluateResponse', {
id: item.msg.id,
evaluate: item.evaluate,
evaluateText: item.evaluateText,
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res) {
Toast.success('评价成功');
} else {
Toast.fail('无数据');
}
});
},
checkEvaluation(item) {
fetchPost('umeai-talkdirector-service/talkInfo/checkEvaluation', {
id: item.msg.id,
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res) {
this.$set(item, 'evaluate', res.data.evaluate);
this.$set(item, 'evaluateText', res.data.evaluateText);
if (res.data.evaluate != null) {
this.$set(item, 'disabled', true);
} else {
this.$set(item, 'disabled', false);
}
} else {
Toast.fail('无数据');
}
});
},
closeTalkInfo() {
fetchPost('umeai-talkdirector-service/talkInfo/closeTalkInfo', {
chatWindowIdUid: this.chatWindow,
closeType: 5,
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res) {
Toast.success('关闭成功');
}
});
},
getquestion() {
fetchPost('umeai-knowledge-service/public/getquestion', {
question: this.editor.txt.text().replace(' ', ''),
appId: this.appId,
agentId: this.agentId
}).then((res) => {
if (res) {
this.tips = res.data;
if (this.tips.length > 0 && this.editor.txt.text() != '') {
this.showTip = true;
} else {
this.showTip = false;
}
}
});
},
comment(item, evaluate) {
if (!item.disabled) {
item.evaluate = evaluate;
}
},
getOS() {
var sUserAgent = navigator.userAgent;
var isWin =
navigator.platform == 'Win32' || navigator.platform == 'Windows';
var isMac =
navigator.platform == 'Mac68K' ||
navigator.platform == 'MacPPC' ||
navigator.platform == 'Macintosh' ||
navigator.platform == 'MacIntel';
if (isMac) return 'Mac';
var isUnix = navigator.platform == 'X11' && !isWin && !isMac;
if (isUnix) return 'Unix';
var isLinux = String(navigator.platform).indexOf('Linux') > -1;
if (isLinux) return 'Linux';
if (isWin) {
var isWin2K =
sUserAgent.indexOf('Windows NT 5.0') > -1 ||
sUserAgent.indexOf('Windows 2000') > -1;
if (isWin2K) return 'Win2000';
var isWinXP =
sUserAgent.indexOf('Windows NT 5.1') > -1 ||
sUserAgent.indexOf('Windows XP') > -1;
if (isWinXP) return 'WinXP';
var isWin2003 =
sUserAgent.indexOf('Windows NT 5.2') > -1 ||
sUserAgent.indexOf('Windows 2003') > -1;
if (isWin2003) return 'Win2003';
var isWinVista =
sUserAgent.indexOf('Windows NT 6.0') > -1 ||
sUserAgent.indexOf('Windows Vista') > -1;
if (isWinVista) return 'WinVista';
var isWin7 =
sUserAgent.indexOf('Windows NT 6.1') > -1 ||
sUserAgent.indexOf('Windows 7') > -1;
if (isWin7) return 'Win7';
var isWin10 =
sUserAgent.indexOf('Windows NT 10') > -1 ||
sUserAgent.indexOf('Windows 10') > -1;
if (isWin10) return 'Win10';
}
return 'other';
},
getBrowser() {
var browser = {};
var userAgent = navigator.userAgent.toLowerCase();
var s;
(s = userAgent.match(/msie ([\d.]+)/))
? (browser.ie = s[1])
: (s = userAgent.match(/firefox/([\d.]+)/))
? (browser.firefox = s[1])
: (s = userAgent.match(/chrome/([\d.]+)/))
? (browser.chrome = s[1])
: (s = userAgent.match(/opera.([\d.]+)/))
? (browser.opera = s[1])
: (s = userAgent.match(/version/([\d.]+).*safari/))
? (browser.safari = s[1])
: 0;
var version = '';
if (browser.ie) {
version = 'IE ' + browser.ie;
} else {
if (browser.firefox) {
version = 'firefox ' + browser.firefox;
} else {
if (browser.chrome) {
version = 'chrome ' + browser.chrome;
} else {
if (browser.opera) {
version = 'opera ' + browser.opera;
} else {
if (browser.safari) {
version = 'safari ' + browser.safari;
} else {
version = '未知浏览器';
}
}
}
}
}
return version;
},
generateUUID() {
let d = new Date().getTime();
if (window.performance && typeof window.performance.now === 'function') {
d += performance.now(); // use high-precision timer if available
}
let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
/[xy]/g,
function (c) {
let r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c == 'x' ? r : (r & 0x3) | 0x8).toString(16);
}
);
uuid = uuid.replace(/-/g, '');
return uuid;
},
initEditor() {
let that = this;
const editor = new E('#toolbar-container', '#text-container');
// 配置菜单栏,删减菜单,调整顺序
editor.config.menus = ['emoticon', 'image'];
editor.config.height = 100;
editor.config.zIndex = 99999;
editor.config.uploadImgServer = '/upload-img';
editor.config.placeholder = '我要提问';
editor.config.showFullScreen = false;
editor.config.showLinkImg = false;
editor.config.customUploadImg = function (resultFiles, insertImgFn) {
// resultFiles 是 input 中选中的文件列表
// insertImgFn 是获取图片 url 后,插入到编辑器的方法
// 上传图片,返回结果,将图片插入到编辑器中
resultFiles.forEach((item) => {
const formData = new FormData();
formData.append('file', item);
reqUpload(uploadApi, formData).then((res) => {
let url = `${getImgUrl}${res.fileInfo.fid}`;
that.asyncSendMessage(`<img src="${url}" />`, '[图片]');
});
});
};
editor.config.onchangeTimeout = 500;
let used = 1;
editor.config.onchange = async (newHtml) => {
that.getFooterHeight()
if (used === 1) {
let dom = editor.$textContainerElem.elems[0];
dom.style.wordBreak = 'break-all';
used = 0;
}
if (editor.txt.html() === '') {
editor.txt.html(' ');
}
const menuAp = document.getElementsByClassName('w-e-menu')
if (menuAp[0].getAttribute('data-title') === '表情') {
const tooltipClass = document.getElementsByClassName('w-e-menu-tooltip')
tooltipClass[0].style.visibility = 'hidden'
}
setTimeout(() => {
that.getquestion();
}, 1000)
};
editor.txt.eventHooks.keyupEvents.push(this.submit);
editor.create();
editor.txt.html(' ');
return editor;
},
submit(e) {
if (event.keyCode == 13) {
this.asyncSendMessage();
}
},
prev() {
this.$refs.swipe.prev();
},
next() {
this.$refs.swipe.next();
},
prev2() {
this.$refs.swipe2.prev();
},
next2() {
this.$refs.swipe2.next();
},
getParamsUser() {
let params = getUrlParams(window.location.href);
this.appId = params.appId || '';
this.agentId = params.agentId || '';
},
handleIsString(item) {
return typeof item.msg === 'string';
},
// 流式输出
handleStream(item, index, type) {
this.robotIndex = index;
try {
sendSSe(JSON.stringify(item), {
onopen: () => {
console.log('enter on open');
},
onmessage: (event) => {
const answer = toJsonParse(event.data, 'object');
this.answer = answer;
this.$set(this.messageData[this.robotIndex], 'resume', '...');
this.$set(this.messageData[this.robotIndex], 'msg', '<p>...</p>');
// 定时器清除
this.handleAnswerTimerClear('中台返回');
this.handleModelAnswerWithMsgType();
},
onclose: (event) => {
console.log('enter onclose::::', event);
},
onerror: () => {
console.log('enter on error::::');
}
});
} catch (error) {
console.log('流式异常')
this.streamFlag = false
this.$set(this.messageData[this.robotIndex], 'resume', '网络异常');
this.$set(this.messageData[this.robotIndex], 'msg', '<p>网络异常</p>');
console.log('异常报错', error);
}
},
// 建立前端获取答案计时器
handleAnswerTimerStart(info) {
answerTimer = setTimeout(() => {
this.isLoading = false;
this.isRenderAll = true;
if (this.messageData[this.robotIndex]) {
this.$set(
this.messageData[this.robotIndex],
'resume',
this.answerText
);
this.$set(
this.messageData[this.robotIndex],
'msg',
`<p>${this.renderText}</p>`
);
// this.$set(this.messageData[this.robotIndex], 'isLoading', false);
// this.$set(this.messageData[this.robotIndex], 'isTimeout', true);
}
closeSSE();
this.handleAnswerTimerClear();
}, 60000);
},
// 清除前端获取答案计时器
handleAnswerTimerClear() {
if (answerTimer) {
clearTimeout(answerTimer);
answerTimer = null;
}
return false;
},
getcandidateTemplates() {
const { candidateTemplates } = this.answer;
this.$set(
this.messageData[this.robotIndex],
'candidateTemplates',
candidateTemplates
);
},
// 普通文本
handleModelAnswerWithMsgType() {
const { content, end } = this.answer;
if (end) {
if (!this.answerText.length) {
// this.$set(this.messageData[this.robotIndex], 'isLoading', false);
// this.$set(this.messageData[this.robotIndex], 'isTimeout', true);
this.$set(this.messageData[this.robotIndex], 'resume', '...');
this.$set(this.messageData[this.robotIndex], 'msg', '<p>...</p>');
}
this.isRenderAll = true;
this.isLoading = false;
}
if (!content) {
return;
}
this.answerText += content;
this.renderAnswer();
this.handleAnswerTimerStart('接收到机器人数据');
},
clearHandle() {
this.isRender = false;
this.isRenderAll = true;
this.isLoading = false;
this.isEnd = false;
this.enable = false;
this.streamFlag = false;
},
// 前端控制答案展示实现打字机效果
async renderAnswer() {
if (this.isRender === true) return;
if (this.currentAnswerIndex >= this.answerText.length) {
// 说明中台答案传输完成并且前端渲染文本答案完成
console.log('看下拼接数据', this.isLoading, this.renderText);
let renderText = this.renderText ? this.renderText.replace(/无查询值也没航班号/g, '') : ''
this.scrollToEnd();
let that = this;
that.$set(
that.messageData[that.robotIndex],
'resume',
renderText
);
that.$set(
that.messageData[that.robotIndex],
'msg',
`<p>${renderText}</p>`
);
this.getcandidateTemplates();
this.clearHandle()
return;
}
this.isRender = true;
const { str: subText, index: textIndex } = splitString(
this.answerText.substring(this.currentAnswerIndex),
20
);
this.currentAnswerIndex =
this.answerText.length >= this.currentAnswerIndex + textIndex
? this.currentAnswerIndex + textIndex
: this.answerText.length;
this.$set(this.messageData[this.robotIndex], 'isLoading', false);
for (let i = 0; i < subText.length;) {
const { str, index } = splitString(subText.substring(i), 2);
this.renderText += str;
i += index;
if (this.messageData[this.robotIndex]) {
this.$set(
this.messageData[this.robotIndex],
'resume',
this.renderText
);
this.$set(
this.messageData[this.robotIndex],
'msg',
`<p>${this.renderText}</p>`
);
}
await sleep(50);
}
await sleep(100);
this.isRender = false;
// this.enable = false;
this.scrollToEnd();
await sleep(100);
this.renderAnswer();
},
// 滚动到页面底部
scrollToEnd() {
scrollTimer = setTimeout(() => {
var div = document.querySelector('#scroll');
div.scrollTop = div.scrollHeight;
// 清除定时器
if (scrollTimer) {
clearTimeout(scrollTimer);
scrollTimer = null;
}
}, 150);
},
getFooterHeight() {
const footer = document.querySelector('.footer');
// 检查是否成功选择到元素
let footerHeight = 0
if (footer) {
// 获取 footer 元素的高度
footerHeight = footer.offsetHeight;
} else {
console.log('Footer element not found.');
}
let currentTip = document.querySelector('.tipAP')
if (currentTip) {
currentTip.style.bottom = footerHeight + 'px'
// currentTip.style.padding = '0.1rem 0.3rem'
}
},
async handleToolBar() {
this.showToolbar = !this.showToolbar
this.showTip = false
let that = this;
setTimeout(() => {
that.getFooterHeight()
this.showTip = true
}, 300)
}
}
};
</script>