@ -0,0 +1,414 @@ |
|||
<template> |
|||
<view class="client-chat" style="height: 100%"> |
|||
<view ref="first" clazz="pop-demo" :use-arrow="true"> |
|||
<view class="pop-demo-list" v-for="pop in popperList" :key="pop.id"> |
|||
<v-button type="link" kind="primary" @click="openSearchUrl(pop, pop.id)"> |
|||
{{ pop.id }}.{{ pop.name }} |
|||
<v-icon remote name="arrow_right_line" size="12" valign="-1"></v-icon> |
|||
</v-button> |
|||
</view> |
|||
</view> |
|||
<view class="qa-item" v-for="(item, index) in msgList" :key="index"> |
|||
<!-- 时间戳 --> |
|||
<view class="timestamp" |
|||
v-if="index === 0 || (index !== 0 && item.timestamp && (Number(item.timestamp) - Number(msgList[index - 1].timestamp)) > timestampGap)"> |
|||
{{ moment(new Date(String(item.timestamp).length === 10 ? item.timestamp * 1000 : |
|||
Number(item.timestamp))).format('MM-DD HH:mm') }} |
|||
</view> |
|||
|
|||
<!-- 问题 --> |
|||
<view class="question-item" v-if="item.is_from_self"> |
|||
<v-spinner status="default" class="qs-loading" v-if="item.is_loading"></v-spinner> |
|||
<!-- <VueMarkdown class="question-text" style="max-width: 352px" :source="item.content" |
|||
:anchorAttributes="{ target: '_blank' }" :linkify="false" /> --> |
|||
</view> |
|||
<!-- 答案 --> |
|||
<view class="answer-item" v-if="!item.is_from_self"> |
|||
<!-- 头像 --> |
|||
<view class="answer-avatar"> |
|||
<img class="robot-avatar" :src="item.from_avatar" /> |
|||
</view> |
|||
|
|||
<!-- 答案信息 --> |
|||
<view class="answer-info" :ref="item.record_id"> |
|||
<view v-if="item.agent_thought && item.agent_thought.procedures && item.agent_thought.procedures.length > 0"> |
|||
<!-- 思考部分 --> |
|||
<MsgThought |
|||
v-for="(thought, i) in item.agent_thought.procedures" |
|||
:key="i" |
|||
:content="thought.debugging.content" |
|||
:title="thought.title" |
|||
:titleIcon="thought.icon" |
|||
:nodeName="thought.name" |
|||
:status="thought.status" |
|||
:elapsed="thought.elapsed" |
|||
:detailVisible="thought.detailVisible" |
|||
/> |
|||
</view> |
|||
<view class="loading" v-if="item.loading_message">正在思考中</view> |
|||
<!-- 回复主体 --> |
|||
<MsgContent :showTags="true" |
|||
:recordId="item.record_id" |
|||
:isReplaceLinks="true" |
|||
:loadingMessage="item.loading_message" |
|||
:content="item.content" |
|||
:isFinal="item.is_final" |
|||
:isMdExpand="item.isMdExpand" |
|||
@littleTagClick="littleTagClick" |
|||
/> |
|||
<!-- 参考来源 --> |
|||
<Reference v-if="item.references && item.references.length>0" :references-list="item.references"/> |
|||
<!-- 运行状态 --> |
|||
<TokensBoardBfr class="tokens-board-class" :showDtl="true" :tokensData="item.tokens_msg"></TokensBoardBfr> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import clone from 'clone'; |
|||
// import VueMarkdown from 'vue-markdown'; |
|||
import elementResizeDetectorMaker from 'element-resize-detector'; |
|||
import { scrollToBottom } from './../utils/util'; |
|||
import { MESSAGE_TYPE, ACCESS_TYPE } from './../constants'; |
|||
import TokensBoardBfr from './tokens-board-brif.vue'; |
|||
import Reference from './reference-component.vue'; |
|||
|
|||
export default { |
|||
name: 'ClientChat', |
|||
components: { |
|||
// VueMarkdown, |
|||
Reference, |
|||
TokensBoardBfr |
|||
}, |
|||
data () { |
|||
return { |
|||
popperList: [], |
|||
oldPopDemo: null, |
|||
loading: false, |
|||
historyLoading: false, |
|||
timestampGap: 5 * 60, // 两次问题间隔大于5min,则展示时间戳(接口侧返回的时间戳是秒级) |
|||
msgList: [], // 对话消息列表 |
|||
robotName: '', // 机器人名称 |
|||
chatBoxHeight: document.body.clientHeight, |
|||
jsScrolling: false, |
|||
userScrolling: false |
|||
}; |
|||
}, |
|||
created () { |
|||
// 监听用户端/管理端体验侧的ws事件 |
|||
this.listenClientAndManageEvent(); |
|||
// 监听公共的ws事件 |
|||
this.listenCommonEvent(); |
|||
}, |
|||
mounted () { |
|||
const erd = elementResizeDetectorMaker(); |
|||
const bodyDom = document.body; |
|||
|
|||
erd.listenTo(bodyDom, (element) => { |
|||
this.chatBoxHeight = element.clientHeight - 113; // 57+56 头部的高度 |
|||
}); |
|||
|
|||
document.addEventListener('click', this.handleOutsideClick); |
|||
|
|||
const sDom = document.querySelector('.client-chat'); |
|||
sDom.addEventListener('scroll', () => { |
|||
if (this.msgList[this.msgList.length - 1].is_final === false && !this.jsScrolling) { |
|||
this.userScrolling = true; |
|||
} else { |
|||
this.jsScrolling = false; |
|||
} |
|||
}); |
|||
}, |
|||
beforeDestroy () { |
|||
// 移除全局事件监听器 |
|||
document.removeEventListener('click', this.handleOutsideClick); |
|||
}, |
|||
methods: { |
|||
openSearchUrl (refer, index) { |
|||
window.open(refer.url); |
|||
}, |
|||
// 监听用户端/管理端体验侧的ws事件 |
|||
listenClientAndManageEvent () { |
|||
// 从缓存获取机器人信息 |
|||
let cachedConfig = null; |
|||
if (ACCESS_TYPE === 'ws') { |
|||
cachedConfig = this.$clientData.getConfigInfo(); |
|||
} else { |
|||
cachedConfig = this.$SseCls.sseQueryConfigInfo(); |
|||
} |
|||
if (cachedConfig) { |
|||
this.robotName = cachedConfig.name; |
|||
} |
|||
|
|||
// 监听答案消息队列变更事件 |
|||
this.$eventHub.$on('client_msgContentChange', (res) => { |
|||
const { chatsContent, type } = res; |
|||
|
|||
// PS:若新消息不属于当前机器人,则在 $clientData 中监听到ws消息后判断并屏蔽。不在此处判断和屏蔽 |
|||
this.renderMsgList(chatsContent, type); |
|||
}); |
|||
}, |
|||
// 监听公共的ws事件 |
|||
listenCommonEvent () { |
|||
this.$eventHub.$on('data_history', () => { |
|||
this.historyLoading = false; |
|||
}); |
|||
|
|||
this.$eventHub.$on('data_historyError', () => { |
|||
this.historyLoading = false; |
|||
}); |
|||
}, |
|||
// 渲染消息会话页面 |
|||
renderMsgList (data, type) { |
|||
// 无需滚动至底部的ws事件:用户端拉取历史记录、用户端停止生成、坐席端取历史记录、点赞点踩 |
|||
const noScrollEvt = [MESSAGE_TYPE.HISTORY, MESSAGE_TYPE.STOP, MESSAGE_TYPE.WORKBENCH_HISTORY, MESSAGE_TYPE.FEEDBACK]; |
|||
const list = data.map(el => { |
|||
return { ...el, showPop: true }; |
|||
}); |
|||
this.msgList = clone(list); |
|||
// console.log('=======更新消list========', clone(list)); |
|||
|
|||
// 对话框滚动至底部(部分ws事件类型无需执行滚动) |
|||
this.$nextTick(() => { |
|||
const sDom = document.querySelector('.client-chat'); |
|||
|
|||
if (!sDom) return; |
|||
|
|||
if (!this.userScrolling && (!noScrollEvt.includes(type))) { |
|||
this.jsScrolling = true; |
|||
scrollToBottom(sDom, sDom.scrollHeight); |
|||
} |
|||
if (this.msgList.length > 0 && this.msgList[this.msgList.length - 1].is_final === true) { |
|||
this.userScrolling = false; |
|||
} |
|||
}); |
|||
}, |
|||
handleOutsideClick (event) { |
|||
if (!this.oldPopDemo) { return; }; |
|||
const firstElement = document.getElementsByClassName('pop-demo')[0]; |
|||
if (this.oldPopDemo.contains(event.target) || firstElement.contains(event.target)) { |
|||
} else { |
|||
if (this.oldPopDemo) { |
|||
this.$refs['first'] && this.$refs['first'].unbindTrigger(this.oldPopDemo); |
|||
} |
|||
// 调用你想要执行的方法 |
|||
this.$refs['first'] && this.$refs['first'].hide(); |
|||
this.oldPopDemo = null; |
|||
} |
|||
}, |
|||
littleTagClick (e, r) { |
|||
const findMsg = this.$clientData.getMsgById(r); |
|||
let innerDome = e.querySelectorAll('.little-tags'); |
|||
let outerTextArr = []; |
|||
if (innerDome && innerDome.length > 0) { |
|||
innerDome.forEach(dom => { |
|||
outerTextArr.push(dom.outerText); |
|||
}); |
|||
} |
|||
this.popperList = findMsg.references.filter(e => outerTextArr.includes(e.id)); |
|||
if (e) { |
|||
this.$refs['first'] && this.$refs['first'].bindTrigger(e, 'manual'); |
|||
this.$refs['first'] && this.$refs['first'].update(); |
|||
this.$refs['first'] && this.$refs['first'].show(); |
|||
this.oldPopDemo = e; |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss"> |
|||
.client-chat::-webkit-scrollbar { |
|||
display: none; |
|||
} |
|||
.pop-demo{ |
|||
// background-color: pink; |
|||
padding: 10px; |
|||
display: flex; |
|||
min-width: var(--size-l); |
|||
padding: var(--spacing-base); |
|||
flex-direction: column; |
|||
justify-content: center; |
|||
// align-items: center; |
|||
gap: var(--spacing-tight); |
|||
|
|||
border-radius: var(--radius-normal); |
|||
border: 0.5px solid var(--color-border-normal); |
|||
|
|||
background: var(--color-bg-2); |
|||
/* shadow/--shadow-medium */ |
|||
box-shadow: var(--shadow-medium-x-1) var(--shadow-medium-y-2) var(--shadow-medium-blur-1) var(--shadow-medium-spread-1) var(--shadow-medium-color-1), var(--shadow-medium-x-2) var(--shadow-medium-y-2) var(--shadow-medium-blur-2) var(--shadow-medium-spread-2) var(--shadow-medium-color-2), var(--shadow-medium-x-3) var(--shadow-medium-y-3) var(--shadow-medium-blur-3) var(--shadow-medium-spread-3) var(--shadow-medium-color-3); |
|||
|
|||
.v-popper__arrow{ |
|||
display: block; |
|||
} |
|||
.pop-demo-list{ |
|||
color: var(--color-link-normal); |
|||
/* caption/--caption-regular */ |
|||
font-family: var(--font-family-normal); |
|||
font-size: 12px; |
|||
font-style: normal; |
|||
font-weight: 400; |
|||
line-height: 16px; /* 133.333% */ |
|||
.v-button { |
|||
text-decoration: none; |
|||
text-align: left; |
|||
} |
|||
} |
|||
} |
|||
.client-chat { |
|||
display: flex; |
|||
flex-direction: column; |
|||
flex: 1; |
|||
overflow-y: overlay; |
|||
padding: 0 12px; |
|||
|
|||
.loading { |
|||
margin: 1em 0; |
|||
width: 150px; |
|||
|
|||
&:after { |
|||
content: "."; |
|||
animation: ellipsis 1.5s steps(1, end) infinite; |
|||
} |
|||
} |
|||
|
|||
@keyframes ellipsis { |
|||
0% { |
|||
content: "."; |
|||
} |
|||
|
|||
33% { |
|||
content: ".."; |
|||
} |
|||
|
|||
66% { |
|||
content: "..."; |
|||
} |
|||
|
|||
100% { |
|||
content: "."; |
|||
} |
|||
} |
|||
|
|||
.qa-item { |
|||
display: flex; |
|||
flex-direction: column; |
|||
margin-bottom: 16px; |
|||
font-weight: 400; |
|||
font-size: 14px; |
|||
color: var(--color-text-primary); |
|||
|
|||
.timestamp { |
|||
font-weight: 400; |
|||
font-size: 12px; |
|||
line-height: 16px; |
|||
text-align: center; |
|||
color: var(--color-text-caption); |
|||
margin: 16px 0; |
|||
} |
|||
|
|||
.question-item { |
|||
display: flex; |
|||
align-items: center; |
|||
width: fit-content; |
|||
text-align: center; |
|||
align-self: flex-end; |
|||
padding-left: 44px; |
|||
|
|||
.qs-error { |
|||
min-width: 16px; |
|||
margin-right: 10px; |
|||
color: var(--color-error-normal); |
|||
} |
|||
|
|||
.qs-loading { |
|||
margin-right: 10px; |
|||
} |
|||
|
|||
.question-text { |
|||
background: #DBE8FF; |
|||
border-radius: 6px; |
|||
padding: 0 12px; |
|||
text-align: left; |
|||
word-break: break-all; |
|||
word-wrap: break-word; |
|||
|
|||
code { |
|||
white-space: break-spaces; |
|||
} |
|||
|
|||
img { |
|||
max-width: 80%; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.summary-item { |
|||
align-self: center; |
|||
margin: 12px 0; |
|||
} |
|||
|
|||
.answer-item { |
|||
display: flex; |
|||
|
|||
.contacter-avatar { |
|||
width: 32px; |
|||
height: 32px; |
|||
border-radius: 50%; |
|||
margin-right: 12px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.answer-info { |
|||
position: relative; |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 12px; |
|||
background: #F4F5F7; |
|||
border-radius: 6px; |
|||
width: calc(100% - 67px); |
|||
|
|||
.answer-expand { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
cursor: pointer; |
|||
width: 44px; |
|||
height: 24px; |
|||
margin-bottom: 12px; |
|||
background: var(--color-bg-2); |
|||
box-shadow: var(--shadow-small-light); |
|||
border-radius: 16px; |
|||
align-self: center; |
|||
} |
|||
|
|||
.stop-ws { |
|||
color: var(--color-text-caption); |
|||
margin-left: 5px; |
|||
} |
|||
|
|||
.answer-source { |
|||
margin: 12px 0; |
|||
font-size: 14px; |
|||
color: var(--color-text-caption); |
|||
text-align: left; |
|||
|
|||
.v-button { |
|||
text-decoration: none; |
|||
text-align: left; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.qa-item:last-child { |
|||
padding-bottom: 120px; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,232 @@ |
|||
<template> |
|||
<view class="pending"> |
|||
{{title}} |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'Pending', |
|||
data() { |
|||
return { |
|||
timeObj: null, |
|||
title: '正在思考中...' |
|||
}; |
|||
}, |
|||
mounted() { |
|||
this.init() |
|||
}, |
|||
beforeDestroy() { |
|||
this.timeObj && clearInterval(this.timeObj); |
|||
}, |
|||
methods: { |
|||
init() { |
|||
this.timeObj = setInterval(() => { |
|||
this.title = this.title == '正在思考中...' ? this.title === '正在思考中..' ? '正在思考中.' : '正在思考中..' : '正在思考中...' |
|||
}, 500) |
|||
} |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss"> |
|||
.client-chat::-webkit-scrollbar { |
|||
display: none; |
|||
} |
|||
.pop-demo { |
|||
// background-color: pink; |
|||
padding: 10px; |
|||
display: flex; |
|||
min-width: var(--size-l); |
|||
padding: var(--spacing-base); |
|||
flex-direction: column; |
|||
justify-content: center; |
|||
// align-items: center; |
|||
gap: var(--spacing-tight); |
|||
|
|||
border-radius: var(--radius-normal); |
|||
border: 0.5px solid var(--color-border-normal); |
|||
|
|||
background: var(--color-bg-2); |
|||
/* shadow/--shadow-medium */ |
|||
box-shadow: var(--shadow-medium-x-1) var(--shadow-medium-y-2) |
|||
var(--shadow-medium-blur-1) var(--shadow-medium-spread-1) |
|||
var(--shadow-medium-color-1), |
|||
var(--shadow-medium-x-2) var(--shadow-medium-y-2) |
|||
var(--shadow-medium-blur-2) var(--shadow-medium-spread-2) |
|||
var(--shadow-medium-color-2), |
|||
var(--shadow-medium-x-3) var(--shadow-medium-y-3) |
|||
var(--shadow-medium-blur-3) var(--shadow-medium-spread-3) |
|||
var(--shadow-medium-color-3); |
|||
|
|||
.v-popper__arrow { |
|||
display: block; |
|||
} |
|||
.pop-demo-list { |
|||
color: var(--color-link-normal); |
|||
/* caption/--caption-regular */ |
|||
font-family: var(--font-family-normal); |
|||
font-size: 12px; |
|||
font-style: normal; |
|||
font-weight: 400; |
|||
line-height: 16px; /* 133.333% */ |
|||
.v-button { |
|||
text-decoration: none; |
|||
text-align: left; |
|||
} |
|||
} |
|||
} |
|||
.client-chat { |
|||
display: flex; |
|||
flex-direction: column; |
|||
flex: 1; |
|||
overflow-y: overlay; |
|||
padding: 0 12px; |
|||
|
|||
.loading { |
|||
margin: 1em 0; |
|||
width: 150px; |
|||
|
|||
&:after { |
|||
content: "."; |
|||
animation: ellipsis 1.5s steps(1, end) infinite; |
|||
} |
|||
} |
|||
|
|||
@keyframes ellipsis { |
|||
0% { |
|||
content: "."; |
|||
} |
|||
|
|||
33% { |
|||
content: ".."; |
|||
} |
|||
|
|||
66% { |
|||
content: "..."; |
|||
} |
|||
|
|||
100% { |
|||
content: "."; |
|||
} |
|||
} |
|||
|
|||
.qa-item { |
|||
display: flex; |
|||
flex-direction: column; |
|||
margin-bottom: 16px; |
|||
font-weight: 400; |
|||
font-size: 14px; |
|||
color: var(--color-text-primary); |
|||
|
|||
.timestamp { |
|||
font-weight: 400; |
|||
font-size: 12px; |
|||
line-height: 16px; |
|||
text-align: center; |
|||
color: var(--color-text-caption); |
|||
margin: 16px 0; |
|||
} |
|||
|
|||
.question-item { |
|||
display: flex; |
|||
align-items: center; |
|||
width: fit-content; |
|||
text-align: center; |
|||
align-self: flex-end; |
|||
padding-left: 44px; |
|||
|
|||
.qs-error { |
|||
min-width: 16px; |
|||
margin-right: 10px; |
|||
color: var(--color-error-normal); |
|||
} |
|||
|
|||
.qs-loading { |
|||
margin-right: 10px; |
|||
} |
|||
|
|||
.question-text { |
|||
background: #dbe8ff; |
|||
border-radius: 6px; |
|||
padding: 0 12px; |
|||
text-align: left; |
|||
word-break: break-all; |
|||
word-wrap: break-word; |
|||
|
|||
code { |
|||
white-space: break-spaces; |
|||
} |
|||
|
|||
img { |
|||
max-width: 80%; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.summary-item { |
|||
align-self: center; |
|||
margin: 12px 0; |
|||
} |
|||
|
|||
.answer-item { |
|||
display: flex; |
|||
|
|||
.contacter-avatar { |
|||
width: 32px; |
|||
height: 32px; |
|||
border-radius: 50%; |
|||
margin-right: 12px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.answer-info { |
|||
position: relative; |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 12px; |
|||
background: #f4f5f7; |
|||
border-radius: 6px; |
|||
width: calc(100% - 67px); |
|||
|
|||
.answer-expand { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
cursor: pointer; |
|||
width: 44px; |
|||
height: 24px; |
|||
margin-bottom: 12px; |
|||
background: var(--color-bg-2); |
|||
box-shadow: var(--shadow-small-light); |
|||
border-radius: 16px; |
|||
align-self: center; |
|||
} |
|||
|
|||
.stop-ws { |
|||
color: var(--color-text-caption); |
|||
margin-left: 5px; |
|||
} |
|||
|
|||
.answer-source { |
|||
margin: 12px 0; |
|||
font-size: 14px; |
|||
color: var(--color-text-caption); |
|||
text-align: left; |
|||
|
|||
.v-button { |
|||
text-decoration: none; |
|||
text-align: left; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.qa-item:last-child { |
|||
padding-bottom: 120px; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,799 @@ |
|||
<template> |
|||
<view class="chat-wrap__main"> |
|||
<view class="chat-wrap__main-content" :style="{ 'margin-bottom': `${chatMainMrgBottom}px` }"> |
|||
<!-- <view>显示内容</view> --> |
|||
<!-- <ClientChat @send="onSendQuestion" /> --> |
|||
<scroll-view class="chat-wrapper" :scroll-into-view="bottom" :scroll-y="true" :scroll-with-animation="true" scroll-into-view-offset="10"> |
|||
<view class="list-container"> |
|||
<!-- for循环 --> |
|||
<view class="chat-list" v-for="n,index in msgList" :key="index"> |
|||
<view class="msg-container other" v-if="n.type =='reply' "> |
|||
<image class="ava" :src="robotObj.headImage"></image> |
|||
<view class="msg"> |
|||
<view class="msg-nickname">{{robotObj.name}}</view> |
|||
<view :class="n.contentType==='img' ? 'msg-content-img' : '' " class="msg-content"> |
|||
<template v-if="n.pending"> |
|||
<Pending></Pending> |
|||
</template> |
|||
<template v-else> |
|||
<template v-if="n.contentType === 'text'"> |
|||
<rich-text v-if="n.contentType === 'text'" :nodes="n.content"></rich-text> |
|||
<view v-if="n.is_final" class="msg-btns"> |
|||
<image mode="widthFix" class="btn-img" @click="copy(n.content)" src="./../../static/imgs/icon-copy.png"></image> |
|||
<image mode="widthFix" class="btn-img" @click="audio(n)" :src="audioActive == n.timestamp ? './../../static/imgs/icon-bf-active.png':'./../../static/imgs/icon-bf.png'"></image> |
|||
</view> |
|||
</template> |
|||
|
|||
<image v-else-if="n.contentType === 'img'" mode="widthFix" class="cimg" src="./../../static/imgs/more.png"></image> |
|||
</template> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
<view v-if="n.type==='send' " class="msg-container self"> |
|||
<view class="msg"> |
|||
<!-- <view class="msg-nickname">本人</view> --> |
|||
<view class="msg-content" :class="n.contentType==='img' ? 'msg-content-img' : '' "> |
|||
<text v-if="n.contentType === 'text'">{{n.content}}</text> |
|||
<image v-else-if="n.contentType === 'img'" mode="widthFix" class="cimg" :src="n.content"></image> |
|||
</view> |
|||
</view> |
|||
<!-- <image class="ava" src="./../../static/imgs/more.png"></image> --> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
<view id="bottom" style="opacity:0"> |
|||
</view> |
|||
</scroll-view> |
|||
|
|||
</view> |
|||
<view class="chat-wrap__main-footer"> |
|||
|
|||
<view class="disabled-loadding" v-if="disabledStatus"></view> |
|||
<view class="chatinput-wrapper"> |
|||
<view class="chatinput-content"> |
|||
<view class="chatinput-btn-wrap"> |
|||
<image :src="videoStatus ? './../../static/imgs/icon-txt.png' :'./../../static/imgs/icon-video.png'" class='chatinput-img' @click="changeVideoStatus"></image> |
|||
</view> |
|||
<view class='chatinput-input-wrap' v-if="videoStatus"> |
|||
<view style="text-align: center; flex: 1" @longpress="startVideo" @touchend="stopVideo"> |
|||
按住 说话 |
|||
</view> |
|||
</view> |
|||
<view v-else class='chatinput-input-wrap'> |
|||
<input v-model="inputValue" @focus="inputFocus" @input="inputChange" @confirm="inputSend" placeholder="想对TA说点什么呢…" confirm-type='send' /> |
|||
</view> |
|||
<view class="chatinput-btn-wrap"> |
|||
<image v-if="!isEditInput" src="./../../static/imgs/icon-pic.png" class='chatinput-img' @click="tapChooseImage"></image> |
|||
<button v-if="isEditInput" class="chatinput-send">发送</button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import Socket from './utils/socket' |
|||
import { splitTextForTTS } from './utils/util' |
|||
import { |
|||
getHistroyMsg, |
|||
} from './utils/message' |
|||
import ClientData from './utils/ClientData'; |
|||
import Pending from './components/pending'; |
|||
export default { |
|||
props: { |
|||
agentId: { |
|||
type: String, |
|||
default: '', |
|||
} |
|||
}, |
|||
components: { |
|||
Pending |
|||
}, |
|||
watch: { |
|||
agentId: { |
|||
handler(val) { |
|||
if (val) { |
|||
this.init(); |
|||
} |
|||
}, |
|||
immediate: true, |
|||
}, |
|||
}, |
|||
data() { |
|||
return { |
|||
title: 'Hello', |
|||
socketObj: null, |
|||
isEditInput: false, |
|||
inputValue: '', |
|||
chatMainMrgBottom: '', |
|||
bottom: '', |
|||
videoStatus: false, |
|||
msgList: [], |
|||
recorderManager: null, |
|||
audioCtx: '', |
|||
audioActive: '', |
|||
$ClientData: '', |
|||
robotObj: {}, |
|||
audioArray: [], |
|||
asrStatus: false, |
|||
tmpMsg: {}, |
|||
lastTxt: {}, |
|||
txtAudioStaus: {}, |
|||
disabledStatus: false |
|||
} |
|||
}, |
|||
onLoad() { |
|||
}, |
|||
mounted() { |
|||
console.log('【init message connect type------>】',); |
|||
// this.init() |
|||
}, |
|||
destroyed() { |
|||
console.log('【destroy message connect type------>】',); |
|||
this.socketObj.selfCloseStatus = true |
|||
this.socketObj.destroy() |
|||
this.audioCtx && this.audioCtx.destroy() |
|||
this.audioCtx = null |
|||
this.audioActive = '' |
|||
this.audioStatus = false |
|||
this.asrStatus = false |
|||
}, |
|||
onShow() { |
|||
this.scrollToBottom() |
|||
}, |
|||
methods: { |
|||
async init() { |
|||
this.audioStatus = true |
|||
this.asrStatus = true |
|||
uni.showLoading() |
|||
console.log('【init message connect type------>】',); |
|||
this.socketObj = new Socket({ |
|||
agentId: this.agentId, |
|||
onMessage: (e) => { |
|||
const index = this.msgList.findIndex(it => it.request_id == e.request_id && it.type == 'reply') |
|||
console.log(e.request_id + '回复内容', e.content) |
|||
// this.tmpMsg[e.request_id] = this.tmpMsg[e.request_id] ?? [] |
|||
// if (this.tmpMsg[e.request_id].length) { |
|||
// const txt = this.removeSpecificText(e.content??'', this.lastTxt[e.request_id] ?? '') |
|||
// if (txt) { |
|||
// this.tmpMsg[e.request_id].push(txt) |
|||
// } |
|||
// } else { |
|||
// this.tmpMsg[e.request_id].push(e.content) |
|||
// } |
|||
// this.lastTxt[e.request_id] = e.content |
|||
// // console.log(this.tmpMsg[e.request_id]) |
|||
// if (!this.txtAudioStaus[e.request_id]) { |
|||
// this.txtAudioStaus[e.request_id] = true |
|||
// this.renderTxt(e) |
|||
// } |
|||
|
|||
// console.log(e.content) |
|||
e.pending = false |
|||
// if (index > -1) { |
|||
// this.msgList[index] = e |
|||
// } else { |
|||
// if (this.msgList.length) { |
|||
// const obj = this.msgList[this.msgList.length - 1] |
|||
// if (obj.pending) { |
|||
// this.$nextTick(() => { |
|||
// this.msgList[this.msgList.length - 1] = e |
|||
// }) |
|||
|
|||
// } else { |
|||
// this.msgList.push(e) |
|||
// } |
|||
|
|||
// } else { |
|||
// this.msgList.push(e) |
|||
// } |
|||
// } |
|||
|
|||
if (!e.is_from_self && e.is_final) { |
|||
const audioParams = e |
|||
console.log( audioParams); |
|||
// 回复结束 写入缓存 |
|||
this.audio(audioParams, (txt)=> { |
|||
const index = this.msgList.findIndex(it => it.request_id == e.request_id && it.type == 'reply') |
|||
if (index > -1) { |
|||
this.$nextTick(() => { |
|||
this.msgList[index].content += txt |
|||
this.scrollToBottom() |
|||
}) |
|||
} else { |
|||
if (this.msgList.length) { |
|||
const obj = this.msgList[this.msgList.length - 1] |
|||
if (obj.pending) { |
|||
this.$nextTick(() => { |
|||
const tmp = { |
|||
...e, |
|||
pending: false, |
|||
content: txt |
|||
} |
|||
this.msgList[this.msgList.length - 1] = tmp |
|||
this.scrollToBottom() |
|||
}) |
|||
} else { |
|||
this.msgList.push(e) |
|||
} |
|||
} else { |
|||
const tmp = { |
|||
...e, |
|||
pending: false |
|||
} |
|||
this.$nextTick(() => { |
|||
this.msgList.push(tmp) |
|||
this.scrollToBottom() |
|||
}) |
|||
} |
|||
} |
|||
this.scrollToBottom() |
|||
}) |
|||
|
|||
// |
|||
this.disabledStatus = false |
|||
} |
|||
this.scrollToBottom() |
|||
} |
|||
}) |
|||
this.getRecord(this.agentId) |
|||
this.scrollToBottom(1000) |
|||
await this.socketObj.init() |
|||
this.$ClientData = new ClientData() |
|||
this.$ClientData.init() |
|||
this.$ClientData.setAttr({ socketObj: this.socketObj }) |
|||
this.robotObj = this.socketObj.robotObj |
|||
wx.hideLoading() |
|||
this.sendMsg() |
|||
}, |
|||
async renderTxt(e) { |
|||
for (let index = 0; index < this.tmpMsg[e.request_id].length; index++) { |
|||
const txt = this.tmpMsg[e.request_id][index]; |
|||
const params = { |
|||
content: txt, |
|||
timestamp: e.timestamp |
|||
} |
|||
await this.audio(params) |
|||
} |
|||
}, |
|||
sendMsg() { |
|||
if (!this.msgList.length) { |
|||
this.$ClientData.triggerSendMsg(this.socketObj.robotObj.firstWord, 'text', false); |
|||
} |
|||
}, |
|||
|
|||
removeSpecificText(originalText, textToRemove) { |
|||
// 使用字符串替换方法删除指定文本 |
|||
return originalText.replace(new RegExp(textToRemove, 'g'), ''); |
|||
}, |
|||
// 复制 |
|||
copy(e) { |
|||
wx.setClipboardData({ |
|||
data: e, |
|||
success: function () { |
|||
wx.showToast({ title: '复制成功' }); |
|||
} |
|||
}); |
|||
}, |
|||
|
|||
async audio(n, cb) { |
|||
console.log('【audio------>】', n); |
|||
const txt = this.stripHtmlTags(n.content) |
|||
if (!txt) { return } |
|||
this.audioCtx && this.audioCtx.destroy() |
|||
this.audioCtx = null |
|||
|
|||
if (this.audioActive == n.timestamp) { |
|||
this.audioActive = '' |
|||
return |
|||
} |
|||
const texts = splitTextForTTS(txt) |
|||
console.log('语言划分', texts) |
|||
|
|||
this.audioActive = n.timestamp |
|||
for (let index = 0; index < texts.length; index++) { |
|||
const txt = texts[index]; |
|||
const nextText = texts[index + 1] ?? '' |
|||
if (nextText) { |
|||
this.loadAudioUrl(nextText) |
|||
} |
|||
await this.audioText(txt, cb) |
|||
} |
|||
this.disabledStatus = false // 语音播发结束 |
|||
this.audioActive = '' |
|||
|
|||
}, |
|||
|
|||
audioText(text, cb) { |
|||
return new Promise(async (resolve, reject) => { |
|||
if (!this.audioStatus) { reject() } |
|||
const url = await this.loadAudioUrl(text) |
|||
console.log('播放文字', text) |
|||
cb && cb(text) |
|||
this.audioCtx = wx.createInnerAudioContext(); |
|||
this.audioCtx.src = url //'data:audio/wav;base64,' + res.data.data |
|||
this.audioCtx.play() |
|||
this.audioCtx.onStop(() => { |
|||
this.delAudioFile(url) |
|||
resolve() |
|||
}) |
|||
this.audioCtx.onEnded(() => { |
|||
this.delAudioFile(url) |
|||
resolve() |
|||
}) |
|||
}); |
|||
}, |
|||
loadAudioUrl(text) { |
|||
if (this.audioArray[text]) { |
|||
return Promise.resolve(this.audioArray[text]) |
|||
} else { |
|||
return new Promise((resolve, reject) => { |
|||
uni.request({ |
|||
method: "post", |
|||
data: { |
|||
text, |
|||
voiceType: this.socketObj.robotObj.voiceType ?? 1002 |
|||
}, |
|||
dataType: "json", |
|||
url: `https://des.js-dyyj.com/xcx/api/voice/tts`, |
|||
success: (res) => { |
|||
console.log('文字合成语音', res) |
|||
if (res.data.code) { |
|||
this.audioArray[text] = res.data.msg |
|||
const audio = wx.createInnerAudioContext(); |
|||
audio.src = res.data.msg; |
|||
audio.onCanplay(() => { |
|||
audio.destroy(); // 加载后释放资源 |
|||
}); |
|||
|
|||
resolve(res.data.msg) |
|||
|
|||
} else { |
|||
wx.showToast({ title: '文字转化失败' }); |
|||
reject() |
|||
} |
|||
|
|||
}, |
|||
fail: (err) => { |
|||
console.log("【init msg-------getDemoToken---->】", err); |
|||
|
|||
}, |
|||
}); |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
stripHtmlTags(html) { |
|||
return html.replace(/<\/?[^>]+(>\$)/g, '') |
|||
.replace(/\u00a0/g, '') |
|||
// .replace(/\s+/g, ' ') |
|||
.replace(/\([^)]*\)/g, '') |
|||
.replace(/\([^)]*\)/g, '') |
|||
.replace(/\<br\/\>/g, '') |
|||
.replace(/\<br\>/g, '') |
|||
}, |
|||
|
|||
getRecord(id) { |
|||
// 获取聊天记录 |
|||
let { msgList } = getHistroyMsg(id) |
|||
if (msgList && msgList.length) { |
|||
this.msgList = msgList.slice(-10) |
|||
} else { |
|||
this.msgList = [] |
|||
} |
|||
}, |
|||
changeVideoStatus() { |
|||
this.videoStatus = !this.videoStatus |
|||
}, |
|||
|
|||
startVideo() { |
|||
if (!this.recorderManager) { |
|||
this.recorderManager = wx.getRecorderManager() |
|||
} |
|||
this.recorderManager.start({ |
|||
duration: 10000, |
|||
sampleRate: 44100, |
|||
numberOfChannels: 1, |
|||
encodeBitRate: 192000, |
|||
format: 'aac', |
|||
frameSize: 50 |
|||
}) |
|||
this.recorderManager.onStart(() => { |
|||
console.log('开始录音') |
|||
wx.showLoading({ |
|||
title: '录音中', |
|||
}) |
|||
}) |
|||
|
|||
this.recorderManager.onStop(async (res) => { |
|||
wx.showLoading({ |
|||
title: '识别中', |
|||
mask: true |
|||
}) |
|||
uni.uploadFile({ |
|||
url: 'https://des.js-dyyj.com/xcx/system/oss/upload', |
|||
filePath: res.tempFilePath, |
|||
name: 'file', |
|||
success: async (res) => { |
|||
let data = JSON.parse(res.data); |
|||
console.log('上传成功', res, data); |
|||
if (!this.asrStatus) { return } |
|||
if (data.code == 200) { |
|||
uni.request({ |
|||
method: "get", |
|||
dataType: "json", |
|||
url: `https://des.js-dyyj.com/xcx/api/voice/asr?audioFilePath=${encodeURI(data.data.url)}`, |
|||
success: (res) => { |
|||
console.log("【init msg-------res---->】", res); |
|||
if (!this.asrStatus) { return } |
|||
if (res.data.code == 200) { |
|||
wx.hideLoading() |
|||
if (res.data.msg) { |
|||
this.onSendQuestion(res.data.msg); |
|||
this.scrollToBottom() |
|||
} else { |
|||
wx.showToast({ |
|||
title: '没有听清,请再说一遍', |
|||
icon: 'none' |
|||
}); |
|||
|
|||
} |
|||
} else { |
|||
wx.showToast({ |
|||
title: '识别失败', |
|||
icon: 'error' |
|||
}); |
|||
} |
|||
}, |
|||
fail: (err) => { |
|||
wx.showToast({ |
|||
title: '识别失败', |
|||
icon: 'error' |
|||
}); |
|||
}, |
|||
complete: () => { |
|||
this.delAudioFile(data.data.url) |
|||
} |
|||
}); |
|||
} |
|||
}, |
|||
fail: (err) => { |
|||
console.log('上传失败', err); |
|||
wx.showToast({ |
|||
title: '识别失败', |
|||
icon: 'error' |
|||
}); |
|||
} |
|||
}); |
|||
}) |
|||
this.recorderManager.onFrameRecorded((res) => { |
|||
const { frameBuffer } = res |
|||
console.log('frameBuffer', frameBuffer) |
|||
// 实时处理音频帧数据 |
|||
this.getVideoText(frameBuffer) |
|||
}) |
|||
}, |
|||
|
|||
delAudioFile(path) { |
|||
if (path) { |
|||
uni.request({ |
|||
method: "get", |
|||
dataType: "json", |
|||
url: `https://des.js-dyyj.com/xcx/api/voice/deleteSingleFile?audioFilePath=${encodeURI(path)}`, |
|||
success: (res) => { |
|||
console.log("【删除文件msg-------res---->】", res); |
|||
|
|||
}, |
|||
fail: (err) => { |
|||
console.log("【init msg-------getDemoToken---->】", err); |
|||
|
|||
}, |
|||
}); |
|||
} |
|||
}, |
|||
|
|||
getVideoText(frameBuffer) { |
|||
const params = { |
|||
Action: 'SentenceRecognition', |
|||
Version: '2019-06-14', |
|||
EngineModelType: '8k_zh', |
|||
ChannelNum: 1, |
|||
VoiceFormat: 'acc', |
|||
ResTextFormat: 3, |
|||
ResTextFormat: 1 |
|||
} |
|||
}, |
|||
stopVideo() { |
|||
console.log('结束录音') |
|||
this.recorderManager && this.recorderManager.stop && this.recorderManager.stop() |
|||
this.recorderManager = null |
|||
}, |
|||
inputSend() { |
|||
console.log('【inputSend------>】',); |
|||
this.onSendQuestion(this.inputValue) |
|||
this.scrollToBottom() |
|||
}, |
|||
inputFocus() { |
|||
console.log('inputFocus------>】',); |
|||
this.scrollToBottom() |
|||
}, |
|||
inputChange() { |
|||
console.log('inputChange------>】',); |
|||
}, |
|||
// *发送图片 |
|||
async tapChooseImage() { |
|||
wx.chooseMedia({ |
|||
count: 1, |
|||
mediaType: ['image'], |
|||
sourceType: ['album', 'camera'], |
|||
success: (res) => { |
|||
uni.uploadFile({ |
|||
url: 'https://des.js-dyyj.com/xcx/system/oss/upload', |
|||
filePath: res.tempFiles[0].tempFilePath, |
|||
name: 'file', |
|||
success: (res) => { |
|||
console.log('上传成功', res); |
|||
let data = JSON.parse(res.data); |
|||
if (data.code == 200) { |
|||
const img = data.data.url |
|||
this.$ClientData.triggerSendMsg(img, 'img'); |
|||
const postData = { |
|||
content: img, |
|||
type: 'send', |
|||
contentType: 'img', |
|||
timestamp: +new Date(), |
|||
chatId: this.agentId |
|||
} |
|||
console.log('postData', postData) |
|||
this.msgList.push(postData) |
|||
// 加入恢复空信息 |
|||
this.inputTmpReply() |
|||
this.scrollToBottom() |
|||
|
|||
this.disabledStatus = true |
|||
} |
|||
|
|||
|
|||
}, |
|||
fail: (err) => { |
|||
console.log('上传失败', err); |
|||
} |
|||
}); |
|||
} |
|||
}) |
|||
}, |
|||
onSendQuestion(e) { |
|||
let self = this |
|||
if (e === '') { |
|||
return wx.showToast({ title: '不能发送空白消息', icon: 'none' }) |
|||
} |
|||
this.disabledStatus = true |
|||
console.log('发送问题', e) |
|||
this.$ClientData.triggerSendMsg(e, 'text'); |
|||
const postData = { |
|||
content: e, |
|||
type: 'send', |
|||
contentType: 'text', |
|||
timestamp: +new Date(), |
|||
chatId: this.agentId |
|||
} |
|||
|
|||
self.msgList.push(postData) |
|||
self.inputValue = '' |
|||
|
|||
this.inputTmpReply() |
|||
}, |
|||
inputTmpReply() { |
|||
// 加入恢复空信息 |
|||
const reData = { |
|||
content: '', |
|||
pending: true, |
|||
type: 'reply', |
|||
contentType: 'text', |
|||
// timestamp: +new Date(), |
|||
chatId: this.agentId, |
|||
nickName: this.socketObj.robotObj.name, |
|||
headImage: this.socketObj.robotObj.headImage, |
|||
} |
|||
this.msgList.push(reData) |
|||
}, |
|||
// !滑倒最底部 |
|||
scrollToBottom(time = 300) { |
|||
this.$nextTick(() => { |
|||
setTimeout(() => { |
|||
this.bottom = '' |
|||
this.$nextTick(function () { |
|||
this.bottom = 'bottom' |
|||
}) |
|||
}, time) |
|||
}); |
|||
} |
|||
|
|||
} |
|||
} |
|||
</script> |
|||
<style scoped lang="scss"> |
|||
.chat-wrap__main { |
|||
display: flex; |
|||
flex-direction: column; |
|||
width: 100%; |
|||
height: 100%; |
|||
min-height: 700px; |
|||
overflow: hidden; |
|||
background: var(--color-bg-0); |
|||
border-radius: 12px; |
|||
|
|||
&-chat-content { |
|||
flex: 1; |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
align-items: flex-end; |
|||
} |
|||
|
|||
&-content { |
|||
height: calc(100% - 80px); |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
align-items: flex-end; |
|||
} |
|||
&-footer { |
|||
position: relative; |
|||
z-index: 3; |
|||
} |
|||
} |
|||
.chat-wrap__main-content { |
|||
flex: 1; |
|||
} |
|||
.chat-wrap__main-footer { |
|||
height: 140rpx; |
|||
padding: 10rpx; |
|||
padding-bottom: 10px; |
|||
padding-bottom: constant(safe-area-inset-bottom); |
|||
padding-bottom: env(safe-area-inset-bottom); |
|||
background-color: #f8f8f8; |
|||
position: relative; |
|||
|
|||
.disabled-loadding { |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
bottom: 0; |
|||
right: 0; |
|||
background-color: rgba($color: #ffffff, $alpha: 0.4); |
|||
z-index: 9; |
|||
} |
|||
} |
|||
// 输入框 |
|||
.chatinput-wrapper { |
|||
width: 100%; |
|||
// border-top: 1px solid #ddd; |
|||
// border-bottom: 1px solid #ddd; |
|||
.chatinput-content { |
|||
height: 100rpx; |
|||
padding: 10rpx 0 20rpx; |
|||
display: flex; |
|||
align-items: center; |
|||
.chatinput-input-wrap { |
|||
flex: 1; |
|||
height: 80rpx; |
|||
padding: 0 20rpx; |
|||
box-sizing: border-box; |
|||
display: flex; |
|||
align-items: center; |
|||
background: #fff; |
|||
input { |
|||
height: 76rpx; |
|||
font-size: 16px; |
|||
display: inline-block; |
|||
width: 100%; |
|||
margin: 10px; |
|||
} |
|||
} |
|||
.chatinput-btn-wrap { |
|||
width: 50px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
image { |
|||
width: 30px; |
|||
height: 30px; |
|||
} |
|||
.chatinput-send { |
|||
height: 35px; |
|||
width: 43px; |
|||
border-radius: 4px; |
|||
font-size: 12px; |
|||
line-height: 35px; |
|||
padding: 0; |
|||
margin: 0 8px; |
|||
color: #fff; |
|||
background: #0097ff; |
|||
&::after { |
|||
border: none; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.chat-wrapper { |
|||
height: 100%; |
|||
.chat-list { |
|||
padding: 10px 0; |
|||
.msg-container { |
|||
display: flex; |
|||
.ava { |
|||
width: 35px; |
|||
height: 35px; |
|||
border-radius: 4px; |
|||
margin: 0 10px; |
|||
} |
|||
.msg-nickname { |
|||
color: #999; |
|||
line-height: 1; |
|||
margin-bottom: 4px; |
|||
} |
|||
.msg-content { |
|||
box-sizing: border-box; |
|||
word-wrap: break-word; |
|||
max-width: 60vw; |
|||
padding: 8px; |
|||
border-radius: 5rpx; |
|||
background: #fff; |
|||
border: 1px solid #e7e7e7; |
|||
max-width: "calc(100vw - 110px)"; |
|||
|
|||
.msg-btns { |
|||
border-top: 1px solid #e7e7e7; |
|||
margin-top: 5px; |
|||
padding-top: 10px; |
|||
|
|||
.btn-img { |
|||
margin: 0 10px; |
|||
width: 20px; |
|||
height: 20px; |
|||
cursor: pointer; |
|||
} |
|||
} |
|||
} |
|||
&.self { |
|||
justify-content: flex-end; |
|||
padding-right: 10px; |
|||
.msg { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-end; |
|||
align-items: flex-end; |
|||
} |
|||
.msg-nickname { |
|||
text-align: right; |
|||
} |
|||
.msg-content { |
|||
width: auto; |
|||
color: #fff; |
|||
background-color: #0097ff; |
|||
border-color: #0097ff; |
|||
} |
|||
} |
|||
&.other { |
|||
.msg { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-end; |
|||
align-items: flex-start; |
|||
} |
|||
} |
|||
.msg-content.msg-content-img { |
|||
background: none; |
|||
border: none; |
|||
padding: 0; |
|||
image { |
|||
max-width: 120px; |
|||
border-radius: 2px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,347 @@ |
|||
import Vue from "vue"; |
|||
import { MESSAGE_TYPE } from "./chat_constant"; |
|||
import { getQueryVariable, generateRequestId, arrayUnique } from "./util"; |
|||
import { v4 as uuidv4 } from "uuid"; |
|||
import GLOBAL from "./global"; |
|||
import Observer from "./observer"; |
|||
const $e = Observer; |
|||
// const $s = this.$s;
|
|||
|
|||
let cache = null; // 缓存
|
|||
let timeoutTasks = {}; // 超时任务管理
|
|||
const msgSendTimeout = 2 * 60 * 1000; // 发送消息超时ms,此处超时默认为2min
|
|||
|
|||
class ClientData { |
|||
constructor(option) { |
|||
cache = { |
|||
session_id: "", // 会话ID
|
|||
configInfo: null, // 配置信息
|
|||
chatsContent: [], // 会话聊天内容
|
|||
systemEvents: [], // 系统事件栈
|
|||
transferInfo: { |
|||
transferStatus: false, |
|||
transferAvatar: "", |
|||
}, // 当前转人工状态
|
|||
}; |
|||
|
|||
this.$s = null |
|||
|
|||
} |
|||
init() { |
|||
// 获取基础配置
|
|||
this.queryConfigInfo(); |
|||
} |
|||
setAttr(options) { |
|||
this.$s =options.socketObj |
|||
} |
|||
// 获取基础配置
|
|||
async queryConfigInfo() { |
|||
try { |
|||
const seatBizId = ''// getQueryVariable("seat_biz_id");
|
|||
console.log("seatBizId", seatBizId); |
|||
const sessionInfo = await this.createSession(); |
|||
console.log("createsession, res", sessionInfo); |
|||
if (sessionInfo.code === 0) { |
|||
cache.seat_biz_id = seatBizId; |
|||
cache.session_id = sessionInfo.data.session_id; |
|||
} else { |
|||
uni.showModal({ |
|||
title: "获取会话ID失败,请重试", |
|||
icon: "none", |
|||
}); |
|||
} |
|||
// 接着获取机器人基础信息
|
|||
const botInfo = { |
|||
code: 0, |
|||
data: { |
|||
name: "测试机器人", |
|||
avatar: |
|||
"https://qbot-1251316161.cos.ap-nanjing.myqcloud.com/avatar.png", |
|||
is_available: true, |
|||
bot_biz_id: "1664519736704069632", |
|||
}, |
|||
}; |
|||
if (botInfo.data) { |
|||
cache.configInfo = botInfo.data; |
|||
cache.configInfo.session_id = sessionInfo.data.session_id; |
|||
$e.$emit("client_configChange", cache.configInfo); |
|||
} else { |
|||
uni.showModal({ |
|||
title: "获取机器人信息失败", |
|||
icon: "none", |
|||
}); |
|||
} |
|||
} catch (e) { |
|||
console.log("获取机器人信息失败", e); |
|||
uni.showModal({ |
|||
title: "获取会话信息失败,请刷新页面重试", |
|||
icon: "none", |
|||
}); |
|||
} |
|||
} |
|||
async createSession() { |
|||
const session_id = uuidv4(); |
|||
return { code: 0, data: { session_id: session_id } }; |
|||
} |
|||
// 消息上行事件(用户端)
|
|||
triggerSendMsg =async (msg, type='text', status= true) => { |
|||
console.log("[triggerSendMsg]", msg, wx); |
|||
if (!cache.configInfo || !cache.configInfo.session_id) { |
|||
await this.queryConfigInfo(); |
|||
} |
|||
const requestId = generateRequestId(); |
|||
const params = { |
|||
request_id: requestId, |
|||
session_id: cache.configInfo ? cache.configInfo.session_id : 0, |
|||
is_msg_status: status |
|||
}; |
|||
if (type == "text") { |
|||
params.content = msg; |
|||
} else if (type == "img") { |
|||
params.content = ``; |
|||
params.realContent = msg; |
|||
} |
|||
|
|||
this.$s.emit("send", params, type); |
|||
} |
|||
|
|||
// 监听token用量和详情事件
|
|||
listenTokenStat() { |
|||
this.$s.on("token_stat", (data) => { |
|||
$e.$emit("token_state_change", data); |
|||
if (data.session_id !== cache.session_id) return; // 若新消息不属于当前机器人时,则不做处理
|
|||
let loadingMsg = cache.chatsContent.find((el) => el.loading_message); |
|||
let loadingText = "思考中"; |
|||
if (loadingMsg) { |
|||
if (data.procedures && data.procedures.length > 0) { |
|||
loadingText = |
|||
data.procedures[data.procedures.length - 1].title || "思考中"; |
|||
} |
|||
let currentList = cache.chatsContent; |
|||
currentList.forEach((el) => { |
|||
if (el.loading_message) { |
|||
el.text = loadingText; |
|||
el.record_id = data.record_id; |
|||
el.tokens_msg = data; |
|||
// 只有标准模式加这个
|
|||
if (GLOBAL.webimToken[0].pattern === "standard") { |
|||
el.is_final = false; |
|||
} |
|||
} |
|||
}); |
|||
$e.$emit("client_msgContentChange", { |
|||
chatsContent: cache.chatsContent, |
|||
type: MESSAGE_TYPE.ANSWER, |
|||
}); |
|||
} else { |
|||
let findedMsg = cache.chatsContent.find( |
|||
(el) => el.record_id === data.record_id |
|||
); |
|||
if (!findedMsg) return; |
|||
findedMsg.tokens_msg = data; |
|||
|
|||
$e.$emit("client_msgContentChange", { |
|||
chatsContent: cache.chatsContent, |
|||
type: MESSAGE_TYPE.ANSWER, |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 组装消息队列数据
|
|||
// 问题确认消息:根据request_id关联覆盖(服务端收到问题后的确认消息)
|
|||
// 答案消息:倒序遍历插入(服务端答案消息)
|
|||
assembleMsgContent(msgList, type) { |
|||
console.log("assembleMsgContent", msgList, type); |
|||
let newMsg = msgList; |
|||
|
|||
if (type === MESSAGE_TYPE.QUESTION) { |
|||
// 发送的问题消息由前端临时插入消息队列
|
|||
cache.chatsContent.push(newMsg); |
|||
} else if (type === MESSAGE_TYPE.ANSWER) { |
|||
if (cache.chatsContent.length < 1) { |
|||
cache.chatsContent.push(newMsg); |
|||
} else { |
|||
let currentList = cache.chatsContent; |
|||
|
|||
timeoutTasks[newMsg.request_id] && |
|||
clearTimeout(timeoutTasks[newMsg.request_id]); |
|||
|
|||
if (currentList.length === 2 && newMsg.can_rating) { |
|||
currentList[0].transferRobot = true; |
|||
} |
|||
if (newMsg.transfer && newMsg.loading_message) { |
|||
currentList.pop(); |
|||
currentList[currentList.length - 1].loading_message = false; |
|||
currentList[currentList.length - 1] = { |
|||
...newMsg, |
|||
...currentList[currentList.length - 1], |
|||
transfer: true, |
|||
transferRobot: false, |
|||
}; |
|||
} else { |
|||
for (let i = currentList.length - 1; i >= 0; i--) { |
|||
const { transfer, quit, transferRobot } = currentList[i]; |
|||
let tmp = { |
|||
...newMsg, |
|||
transfer, |
|||
quit, |
|||
transferRobot, |
|||
}; |
|||
// 保留tokens_msg,防止覆盖
|
|||
if (currentList[i].tokens_msg) { |
|||
tmp = { ...tmp, tokens_msg: currentList[i].tokens_msg }; |
|||
} |
|||
// 保留thought 放置被覆盖
|
|||
if (currentList[i].agent_thought) { |
|||
tmp = { ...tmp, agent_thought: currentList[i].agent_thought }; |
|||
} |
|||
// 保留reference
|
|||
if (currentList[i].references) { |
|||
tmp = { ...tmp, references: currentList[i].references }; |
|||
} |
|||
// 答案消息流式输出覆盖(record_id)
|
|||
if (newMsg.record_id === currentList[i].record_id) { |
|||
currentList[i] = tmp; |
|||
break; |
|||
} |
|||
// 服务端问题消息确认数据,覆盖前端插入的临时问题消息数据(request_id匹配 & 自己发出的问题消息)
|
|||
if ( |
|||
newMsg.request_id && |
|||
newMsg.request_id === currentList[i].request_id && |
|||
newMsg.is_from_self |
|||
) { |
|||
newMsg.is_loading = false; // 服务端确认收到问题消息,则去除”发送中“状态
|
|||
currentList[i] = tmp; |
|||
// 非人工状态时, 并且用户发送的不是敏感消息。插入临时[正在思考中...]消息
|
|||
if (!newMsg.is_evil && !cache.transferInfo.transferStatus) { |
|||
currentList.push({ |
|||
loading_message: true, |
|||
is_from_self: false, |
|||
content: "", |
|||
from_avatar: cache.configInfo.avatar, |
|||
timestamp: Number(currentList[i].timestamp), // 精确到秒
|
|||
}); |
|||
} |
|||
break; |
|||
} |
|||
// 插入最新答案消息
|
|||
if (Number(newMsg.timestamp) >= Number(currentList[i].timestamp)) { |
|||
if (currentList[i].loading_message) { |
|||
// 删除原来的[正在思考中...]消息
|
|||
currentList[currentList.length - 1] = newMsg; |
|||
} else { |
|||
currentList.splice(i + 1, 0, newMsg); |
|||
} |
|||
break; |
|||
} |
|||
if ( |
|||
i === 0 && |
|||
Number(newMsg.timestamp) < Number(currentList[i].timestamp) |
|||
) { |
|||
currentList.splice(0, 0, newMsg); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} else if (type === MESSAGE_TYPE.HISTORY) { |
|||
let currentList = cache.chatsContent; |
|||
// 历史数据打上标签,无需展示”重新生成“和”停止生成“操作
|
|||
msgList = msgList.map((r) => { |
|||
return { |
|||
...r, |
|||
is_history: true, |
|||
is_final: true, |
|||
}; |
|||
}); |
|||
|
|||
if (currentList.length === 0) { |
|||
// 若消息队列为空(用户端,初始拉取历史记录,用做判断欢迎页展示场景)
|
|||
cache.chatsContent = [].concat(msgList); |
|||
} else { |
|||
// 若消息队列不为空
|
|||
let oldMsgCurrent = currentList[0]; |
|||
let newMsgHistory = msgList[msgList.length - 1]; |
|||
|
|||
// 将历史数据拼装到消息队列中(按照时间戳重排数据)
|
|||
if (Number(newMsgHistory.timestamp) < Number(oldMsgCurrent.timestamp)) { |
|||
cache.chatsContent = [].concat(msgList).concat(cache.chatsContent); |
|||
} else { |
|||
msgList.reverse().forEach((msg) => { |
|||
for (let i = 0; i < cache.chatsContent.length; i++) { |
|||
if (msg.record_id === cache.chatsContent[i].record_id) { |
|||
// 重复覆盖
|
|||
cache.chatsContent[i] = msg; |
|||
break; |
|||
} else if ( |
|||
Number(msg.timestamp) <= Number(cache.chatsContent[i].timestamp) |
|||
) { |
|||
cache.chatsContent.splice(i, 0, msg); |
|||
break; |
|||
} else if ( |
|||
i === cache.chatsContent.length - 1 && |
|||
Number(msg.timestamp) > Number(cache.chatsContent[i].timestamp) |
|||
) { |
|||
cache.chatsContent.splice(i + 1, 0, msg); |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 消息去重。同一record_id取最新,同时保留消息最早的时间戳
|
|||
cache.chatsContent = arrayUnique( |
|||
cache.chatsContent, |
|||
"record_id", |
|||
"timestamp" |
|||
); |
|||
|
|||
// 消息队列变更通知事件
|
|||
$e.$emit("client_msgContentChange", { |
|||
chatsContent: cache.chatsContent, |
|||
type, |
|||
}); |
|||
} |
|||
// 修改指定msgId的消息内容
|
|||
modifyMsgContent(msgId) { |
|||
const findedMsg = this.getMsgById(msgId); |
|||
|
|||
if (findedMsg) { |
|||
findedMsg.is_final = true; |
|||
findedMsg.content = findedMsg.content.concat( |
|||
`<span class="stop-ws">| 已停止生成</span>` |
|||
); |
|||
|
|||
$e.$emit("client_msgContentChange", { |
|||
chatsContent: cache.chatsContent, |
|||
type: MESSAGE_TYPE.STOP, // ”停止生成“事件
|
|||
}); |
|||
} |
|||
} |
|||
// 根据msgId获取消息
|
|||
getMsgById(msgId) { |
|||
const findedMsg = cache.chatsContent.find((r) => r.record_id === msgId); |
|||
return findedMsg; |
|||
} |
|||
// 根据msgId获取其关联问题消息
|
|||
getQmsgById(msgId) { |
|||
let findedQmsg = null; |
|||
const findedMsg = this.getMsgById(msgId); |
|||
|
|||
if (findedMsg) { |
|||
findedQmsg = cache.chatsContent.find( |
|||
(r) => r.record_id === findedMsg.related_record_id |
|||
); |
|||
} |
|||
|
|||
return findedQmsg; |
|||
} |
|||
releaseCache() {} |
|||
destroy() { |
|||
// be careful to clear the cache to avoid errors
|
|||
this.releaseCache(); |
|||
} |
|||
} |
|||
|
|||
export default ClientData; |
@ -0,0 +1,50 @@ |
|||
import Vue from 'vue'; |
|||
|
|||
class EventHub { |
|||
constructor (vm) { |
|||
this.vm = vm; |
|||
this.curVm = null; |
|||
this.events = {}; |
|||
this.eventMapUid = {}; |
|||
} |
|||
$register (vm) { |
|||
this.curVm = vm; |
|||
} |
|||
setEventMapUid (uid, type) { |
|||
if (!this.eventMapUid[uid]) { |
|||
this.eventMapUid[uid] = []; |
|||
} |
|||
this.eventMapUid[uid].push(type); |
|||
} |
|||
$on (type, fn) { |
|||
if (!this.events[type]) { |
|||
this.events[type] = []; |
|||
} |
|||
this.events[type].push(fn); |
|||
if (this.curVm instanceof this.vm) { |
|||
this.setEventMapUid(this.curVm._uid, type); |
|||
} |
|||
} |
|||
$emit (type, ...args) { |
|||
if (this.events[type]) { |
|||
this.events[type].forEach(fn => fn(...args)); |
|||
} |
|||
} |
|||
$off (type, fn) { |
|||
if (fn && this.events[type]) { |
|||
const index = this.events[type].findIndex(f => f === fn); |
|||
if (index !== -1) { |
|||
this.events[type].splice(index, 1); |
|||
} |
|||
return; |
|||
} |
|||
delete this.events[type]; |
|||
} |
|||
$offAll (uid) { |
|||
const curAllTypes = this.eventMapUid[uid] || []; |
|||
curAllTypes.forEach(type => this.$off(type)); |
|||
delete this.eventMapUid[uid]; |
|||
} |
|||
} |
|||
|
|||
export default new EventHub(Vue);; |
@ -0,0 +1,12 @@ |
|||
// type: Q-问题,A-答案,H-历史消息,S-停止生成,C-结束会话,T-转接会话,R-参考来源,F-点赞/点踩回执
|
|||
export const MESSAGE_TYPE = { |
|||
QUESTION: 'Q', |
|||
ANSWER: 'A', |
|||
HISTORY: 'H', |
|||
STOP: 'S', |
|||
CLOSE: 'C', |
|||
TRANSFER: 'T', |
|||
REFERENCE: 'R', |
|||
FEEDBACK: 'F', |
|||
WORKBENCH_HISTORY: 'WH' |
|||
}; |
@ -0,0 +1,8 @@ |
|||
wx.GLOBE = { |
|||
webimToken: [], |
|||
SOCKET: null, |
|||
} |
|||
|
|||
|
|||
|
|||
export default wx.GLOBE |
@ -0,0 +1,56 @@ |
|||
/** |
|||
* !插入数据 |
|||
* @param roomId |
|||
* @param sourceId |
|||
* @param orderId |
|||
* @param type |
|||
* @param fileId |
|||
* @param txt |
|||
* @param time |
|||
* @param userId |
|||
* @param nickName |
|||
* @param sendOrReceive |
|||
* @param address |
|||
*/ |
|||
export const setMsgData = data => { |
|||
console.log('setMsgData', data) |
|||
const resData = data |
|||
const session_id = resData.chatId // 群id
|
|||
|
|||
let msgData = wx.getStorageSync('imMsgData') || {} |
|||
// * 插入群数据
|
|||
if (msgData[session_id]) { |
|||
if (resData.timestamp > msgData[session_id].timestamp) { |
|||
msgData[session_id].timestamp = resData.timestamp |
|||
} |
|||
msgData[session_id].listMsg.push(resData) |
|||
} else { |
|||
msgData[session_id] = { |
|||
listMsg: [resData], |
|||
timestamp: resData.timestamp, |
|||
session_id: resData.session_id, |
|||
} |
|||
} |
|||
wx.setStorageSync('imMsgData', msgData) |
|||
return resData |
|||
} |
|||
|
|||
|
|||
// ! 获取群消息
|
|||
export const getHistroyMsg = id => { |
|||
let msgData = wx.getStorageSync('imMsgData') || {} |
|||
let msgList = [] |
|||
if (msgData[id]) { |
|||
const data = msgData[id] |
|||
// *处理历史消息并按时间排序
|
|||
const compare = property => { |
|||
return function(a, b) { |
|||
var value1 = a[property] |
|||
var value2 = b[property] |
|||
return value1 - value2 |
|||
} |
|||
} |
|||
msgList = data.listMsg.sort(compare('time')) |
|||
} |
|||
return { msgList } |
|||
} |
@ -0,0 +1,45 @@ |
|||
import GLOBAL from './global'; |
|||
class Observer { |
|||
constructor() { |
|||
this._event = {} |
|||
} |
|||
$on(eventName, handler) { |
|||
if (this._event[eventName]) { |
|||
this._event[eventName].push(handler) |
|||
} else { |
|||
this._event[eventName] = [handler] |
|||
} |
|||
} |
|||
|
|||
$emit(eventName) { |
|||
let events = this._event[eventName] |
|||
let otherArgs = Array.prototype.slice.call(arguments, 1) |
|||
let that = this |
|||
if (events) { |
|||
events.forEach(event => { |
|||
event.apply(that, otherArgs) |
|||
}) |
|||
} |
|||
} |
|||
$off(eventName, handler) { |
|||
let events = this._event[eventName] |
|||
if (events) { |
|||
this._event[eventName] = events.filter(event => { |
|||
return event !== handler |
|||
}) |
|||
} |
|||
} |
|||
$once(eventName, handler) { |
|||
let that = this |
|||
function func() { |
|||
let args = Array.prototype.slice.call(arguments, 0) |
|||
handler.apply(that, args) |
|||
this.off(eventName, func) |
|||
} |
|||
this.on(eventName, func) |
|||
} |
|||
} |
|||
if (!GLOBAL.observer) { |
|||
GLOBAL.observer = new Observer() |
|||
} |
|||
export default GLOBAL.observer |
@ -0,0 +1,283 @@ |
|||
import Vue from "vue"; |
|||
import GLOBAL_OBJ from "./global"; |
|||
import "./EventHub"; |
|||
import { setMsgData } from "./message"; |
|||
// 心跳间隔
|
|||
const HEART_BEAT_TIME = 20000; |
|||
// 心跳最大失败次数(超过此次数重连)
|
|||
const HEART_BEAT_FAIL_NUM = 1; |
|||
// 重连间隔
|
|||
const RECONNECT_TIME = 1000; |
|||
|
|||
export default class Socket { |
|||
constructor(option) { |
|||
this.socket = null; |
|||
this._options = option; |
|||
this.timeoutObj = null; |
|||
this.robotObj = {}; |
|||
this.selfCloseStatus = false |
|||
this.reconnectLock = false |
|||
console.log("Socket init", GLOBAL_OBJ); |
|||
} |
|||
getToken() { |
|||
return new Promise((resolve, reject) => { |
|||
uni.request({ |
|||
method: "GET", |
|||
dataType: "json", |
|||
// url: `http://192.168.124.118:8083/xcx/framework/agent/${this._options.agentId}`,
|
|||
url: `https://des.js-dyyj.com/getDemoToken?id=${this._options.agentId}`, |
|||
success: (res) => { |
|||
console.log("请求token成功", res); |
|||
resolve(res); |
|||
}, |
|||
fail: (err) => { |
|||
wx.showToast({ |
|||
title: "创建失败", |
|||
icon: "none", |
|||
}); |
|||
console.log("请求token失败", err); |
|||
reject() |
|||
}, |
|||
}); |
|||
}); |
|||
} |
|||
async init() { |
|||
return this.createSocket(); |
|||
} |
|||
async createSocket(options) { |
|||
return new Promise(async (resolve, reject) => { |
|||
const origin = "wss://wss.lke.cloud.tencent.com"; |
|||
let path = "/v1/qbot/chat/conn/"; |
|||
let initSocket = 1; |
|||
let mainToken |
|||
const res = await this.getToken(); |
|||
if ( |
|||
res && |
|||
res.data && |
|||
res.data.apiResponse && |
|||
res.data.apiResponse.Token |
|||
) { |
|||
mainToken = res.data.apiResponse.Token; |
|||
} |
|||
// 机器人信息
|
|||
this.robotObj = res.data.requestInfo; |
|||
console.log("获取token:", mainToken); |
|||
|
|||
// 建立连接
|
|||
const socket = wx.connectSocket({ |
|||
url: `${origin}${path}?EIO=4&transport=websocket`, |
|||
success: (e) => { |
|||
console.log("创建链接成功", e, socket); |
|||
}, |
|||
complete: (e) => { |
|||
console.log("socket - complete", e); |
|||
|
|||
}, |
|||
}); |
|||
this.socket = socket; |
|||
GLOBAL_OBJ.SOCKET = this; |
|||
|
|||
let systemEventEmit = (eventName, data) => { |
|||
Vue.prototype.$eventHub.$emit(eventName, data); |
|||
}; |
|||
|
|||
socket.onOpen((e) => { |
|||
// 监听发送
|
|||
if (initSocket === 1) { |
|||
const token = mainToken || ""; |
|||
if (token) { |
|||
// cb({ token: token });
|
|||
this.send({ |
|||
data: |
|||
"40" + |
|||
JSON.stringify({ |
|||
token: token, |
|||
}), |
|||
}); |
|||
} else { |
|||
// cb({ token: '' });
|
|||
this.send({ |
|||
data: JSON.stringify({ |
|||
token: "", |
|||
}), |
|||
}); |
|||
} |
|||
initSocket++; |
|||
} else { |
|||
const token = mainToken || ""; |
|||
// cb({ token: token });
|
|||
this.send({ |
|||
data: JSON.stringify({ |
|||
token: token, |
|||
}), |
|||
}); |
|||
initSocket++; |
|||
} |
|||
|
|||
options && options.complete && options.complete() |
|||
}); |
|||
|
|||
socket.onMessage((e) => { |
|||
// console.log("socket.onMessage", e);
|
|||
const { data } = e; |
|||
if (data == 2) { |
|||
// 触发浪涌
|
|||
this.send({ |
|||
data: 3, |
|||
}); |
|||
} |
|||
|
|||
const params = this.getMsgData(data); |
|||
const { type } = params ?? {}; |
|||
this.on(type, params); |
|||
if (params && params.type === "reply" && !params.is_from_self) { |
|||
// 回复消息
|
|||
this._options.onMessage && this._options.onMessage(params); |
|||
} |
|||
const num = "" + data.substring(0, 2); |
|||
if (num == "40") { |
|||
resolve(); |
|||
} |
|||
|
|||
this.createInter(); |
|||
}); |
|||
// 失败
|
|||
socket.onError((e) => { |
|||
console.log("websocket error 长链接报错", e); |
|||
// 失败重建
|
|||
this.doConnectTimeout(); |
|||
//
|
|||
}); |
|||
// 关闭
|
|||
socket.onClose((e) => { |
|||
console.log("websocket close 长链接关闭", e); |
|||
this.connectSocketTimeOut && clearTimeout(this.connectSocketTimeOut); |
|||
//非自动关闭重连
|
|||
if (e.code == 1006) { |
|||
this.doConnectTimeout(); |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
doConnectTimeout() { |
|||
// 重连一次
|
|||
if (!this.reconnectLock) { |
|||
this.reconnectLock = true; |
|||
this.connectSocketTimeOut = setTimeout(() => { |
|||
this.createSocket({ |
|||
complete: function (res) { |
|||
this.reconnectLock = false; |
|||
}, |
|||
}); |
|||
}, RECONNECT_TIME); |
|||
} |
|||
} |
|||
|
|||
onConnect(e) { |
|||
console.log("websocket connect", e); |
|||
} |
|||
|
|||
send(e, t) { |
|||
this.socket && this.socket.send(e); |
|||
this.createInter(); |
|||
} |
|||
|
|||
getMsgData(e) { |
|||
if (e && typeof e == "string") { |
|||
const status = e.indexOf("42"); |
|||
const index = e.indexOf("["); |
|||
if (status > -1 && index > -1) { |
|||
const txt = e.substring(index); |
|||
const item = JSON.parse(txt); |
|||
const [type, obj] = item; |
|||
const payload = obj.payload ? obj.payload : {}; |
|||
const params = { |
|||
chatId: this._options.agentId, |
|||
type, |
|||
contentType: "text", //默认文字
|
|||
timestamp: new Date().getTime(), |
|||
...payload, |
|||
}; |
|||
// 缓存聊天记录
|
|||
return params; |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
emit(type, params, contentType) { |
|||
console.log("emit", type, params); |
|||
const data = { |
|||
payload: params, |
|||
}; |
|||
switch (type) { |
|||
case "send": |
|||
// 发送消息
|
|||
const socketParams = { data: "42" + JSON.stringify(["send", data]) }; |
|||
this.send(socketParams, contentType); |
|||
|
|||
console.log(params.request_id + '发送内容', data) |
|||
if (!params.is_msg_status) { |
|||
// 不写入缓存中
|
|||
return; |
|||
} |
|||
const msgParams = { |
|||
chatId: this._options.agentId, |
|||
contentType, |
|||
type, |
|||
timestamp: new Date().getTime(), |
|||
...params, |
|||
content: params.realContent ? params.realContent : params.content, |
|||
}; |
|||
msgParams && setMsgData(msgParams); |
|||
|
|||
break; |
|||
} |
|||
} |
|||
// 监听
|
|||
on(type, params) { |
|||
switch (type) { |
|||
case "reply": |
|||
// 回复结束
|
|||
const tmpParams = { |
|||
...params, |
|||
name: this.robotObj.name, |
|||
headImage: this.robotObj.headImage, |
|||
}; |
|||
if ( |
|||
params && |
|||
params.type === "reply" && |
|||
!params.is_from_self && |
|||
params.can_rating |
|||
) { |
|||
// 回复消息
|
|||
this._options.onMessage && this._options.onMessage(tmpParams); |
|||
} |
|||
|
|||
if (!params.is_from_self && params.is_final) { |
|||
// 回复结束 写入缓存
|
|||
setMsgData(tmpParams); |
|||
} |
|||
// 回复
|
|||
break; |
|||
} |
|||
} |
|||
|
|||
// 关闭socket
|
|||
destroy() { |
|||
if (this.socket && this.socket.readyState == 1) { |
|||
this.socket && this.socket.close(); |
|||
this.socket = null; |
|||
} |
|||
} |
|||
|
|||
createInter() { |
|||
if (this.timeoutObj) { |
|||
clearTimeout(this.timeoutObj); |
|||
} |
|||
this.timeoutObj = setTimeout(() => { |
|||
this.socket && this.socket.send && this.socket.send({ data: 3 }); |
|||
}, HEART_BEAT_TIME); |
|||
} |
|||
} |
@ -0,0 +1,119 @@ |
|||
/** |
|||
* 获取 location hash 参数 |
|||
* @param {string} variable 参数名 |
|||
* @returns {string} 参数值 |
|||
*/ |
|||
export const getQueryVariable = (variable) => { |
|||
const query = window.location.hash.split('?'); |
|||
|
|||
if (query.length < 2) { |
|||
return ''; |
|||
} |
|||
|
|||
const vars = query[1].split('&'); |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|||
for (let i = 0; i < vars.length; i++) { |
|||
const pair = vars[i].split('='); |
|||
if (pair[0] === variable) { |
|||
return decodeURI(pair[1]); |
|||
} |
|||
} |
|||
return ''; |
|||
}; |
|||
|
|||
/** |
|||
* 滚动至指定dom底部 |
|||
*/ |
|||
export const scrollToBottom = (sDom, sTop) => { |
|||
if (!sDom) return; |
|||
sDom.scrollTo({ |
|||
top: sTop |
|||
// behavior: 'smooth'
|
|||
}); |
|||
}; |
|||
|
|||
/** |
|||
* 数组去重 |
|||
*/ |
|||
export const arrayUnique = (arr, replaceKey, holdKey) => { |
|||
let temp = {}; |
|||
|
|||
return arr.reduce((prev, cur) => { |
|||
if (!temp[cur[replaceKey]]) { |
|||
temp[cur[replaceKey]] = {index: prev.length}; |
|||
prev.push(cur); |
|||
} else { |
|||
const oldItem = temp[cur[replaceKey]]; |
|||
cur[holdKey] = oldItem[holdKey]; |
|||
prev.splice(oldItem['index'], 1, cur); |
|||
} |
|||
|
|||
return prev; |
|||
}, []); |
|||
}; |
|||
|
|||
export const generateRequestId = (length = 10) => { |
|||
const data = |
|||
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', |
|||
'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', |
|||
'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', |
|||
's', 't', 'u', 'v', 'w', 'x', 'y', 'z']; |
|||
let nums = ''; |
|||
for (let i = 0; i < length; i++) { |
|||
const r = parseInt(Math.random() * 61, 10); |
|||
nums += data[r]; |
|||
} |
|||
return nums + '-' + parseInt(Math.random() * 10000000000, 10); |
|||
}; |
|||
|
|||
function escapeHtml (str) { |
|||
return str.replace(/[&<>"'/]/g, function (match) { |
|||
return { |
|||
'&': '&', |
|||
'<': '<', |
|||
'>': '>', |
|||
'"': '"', |
|||
"'": ''', |
|||
'/': '/' |
|||
}[match]; |
|||
}); |
|||
} |
|||
|
|||
|
|||
export const splitTextForTTS = (text, maxLength = 30) => { |
|||
// 定义优先分割的标点符号
|
|||
const punctuation = ['。', ',', ';', '?', '!', ',', ';', '?', '!', '、']; |
|||
let segments = []; |
|||
let segment = ''; |
|||
let tempSegment = ''; |
|||
|
|||
for (let i = 0; i < text.length; i++) { |
|||
tempSegment += text[i]; // 如果超过最大长度,则尝试在上一个标点符号处分割
|
|||
|
|||
if (tempSegment.length > maxLength) { |
|||
let lastPunctuationIndex = -1; |
|||
for (let j = tempSegment.length - 1; j >= 0; j--) { |
|||
if (punctuation.includes(tempSegment[j])) { |
|||
lastPunctuationIndex = j; |
|||
break; |
|||
} |
|||
} // 如果找到标点符号,则在标点符号后分割
|
|||
|
|||
if (lastPunctuationIndex !== -1) { |
|||
segments.push(tempSegment.slice(0, lastPunctuationIndex + 1).trim()); |
|||
tempSegment = tempSegment.slice(lastPunctuationIndex + 1).trim(); |
|||
} else { |
|||
// 如果没有找到标点符号,则在最大长度处分割
|
|||
segments.push(tempSegment.slice(0, maxLength).trim()); |
|||
tempSegment = tempSegment.slice(maxLength).trim(); |
|||
} |
|||
} |
|||
} // 添加最后一个段落
|
|||
|
|||
if (tempSegment.length > 0) { |
|||
segments.push(tempSegment.trim()); |
|||
} |
|||
|
|||
return segments; |
|||
} |
@ -1,320 +1,299 @@ |
|||
<template> |
|||
<view class="waterfall-layout"> |
|||
<view class="waterfall-container"> |
|||
<!-- 左列 --> |
|||
<view class="column"> |
|||
<view |
|||
v-for="(item, index) in leftItems" |
|||
:key="item.id || index" |
|||
class="waterfall-item" |
|||
@click="handleItemClick(item)" |
|||
> |
|||
<image |
|||
v-if="item.image" |
|||
:src="showImg(item.image)" |
|||
class="item-image" |
|||
mode="aspectFill" |
|||
/> |
|||
<view class="item-content"> |
|||
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
|||
<view class="item-footer"> |
|||
<view class="user-info"> |
|||
<image |
|||
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
|||
class="user-avatar" |
|||
mode="aspectFill" |
|||
/> |
|||
<text class="username">风景之旅</text> |
|||
</view> |
|||
<view class="like-info"> |
|||
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" style="height: 22rpx;width: 25rpx;"></image> |
|||
<text class="like-count">100</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 右列 --> |
|||
<view class="column"> |
|||
<view |
|||
v-for="(item, index) in rightItems" |
|||
:key="item.id || index" |
|||
class="waterfall-item" |
|||
@click="handleItemClick(item)" |
|||
|
|||
> |
|||
<image |
|||
v-if="item.image" |
|||
:src="showImg(item.image)" |
|||
class="item-image" |
|||
mode="aspectFill" |
|||
/> |
|||
<view class="item-content"> |
|||
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
|||
<view class="item-footer"> |
|||
<view class="user-info"> |
|||
<image |
|||
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
|||
class="user-avatar" |
|||
mode="aspectFill" |
|||
/> |
|||
<text class="username">风景之旅</text> |
|||
</view> |
|||
<view class="like-info"> |
|||
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" style="height: 22rpx;width: 25rpx;"></image> |
|||
<text class="like-count">120</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
<view class="waterfall-layout"> |
|||
<view class="waterfall-container"> |
|||
<!-- 左列 --> |
|||
<view class="column"> |
|||
<view v-for="(item, index) in leftItems" :key="item.id || index" class="waterfall-item" |
|||
@click="handleItemClick(item)"> |
|||
<image v-if="item.image" :src="showImg(item.image)" class="item-image" mode="aspectFill" /> |
|||
<view class="item-content"> |
|||
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
|||
<view class="item-footer"> |
|||
<view class="user-info"> |
|||
<image |
|||
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
|||
class="user-avatar" mode="aspectFill" /> |
|||
<text class="username">风景之旅</text> |
|||
</view> |
|||
<view class="like-info"> |
|||
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" |
|||
style="height: 22rpx;width: 25rpx;"></image> |
|||
<text class="like-count">100</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 右列 --> |
|||
<view class="column"> |
|||
<view v-for="(item, index) in rightItems" :key="item.id || index" class="waterfall-item" |
|||
@click="handleItemClick(item)"> |
|||
<image v-if="item.image" :src="showImg(item.image)" class="item-image" mode="aspectFill" /> |
|||
<view class="item-content"> |
|||
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
|||
<view class="item-footer"> |
|||
<view class="user-info"> |
|||
<image |
|||
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
|||
class="user-avatar" mode="aspectFill" /> |
|||
<text class="username">风景之旅</text> |
|||
</view> |
|||
<view class="like-info"> |
|||
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" |
|||
style="height: 22rpx;width: 25rpx;"></image> |
|||
<text class="like-count">120</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: "WaterfallLayout", |
|||
props: { |
|||
// 数据源 |
|||
items: { |
|||
type: Array, |
|||
default: () => [], |
|||
}, |
|||
// 列数(固定为2列) |
|||
columnCount: { |
|||
type: Number, |
|||
default: 2, |
|||
}, |
|||
// 列间距(rpx) |
|||
columnGap: { |
|||
type: Number, |
|||
default: 16, |
|||
}, |
|||
// 项目间距(rpx) |
|||
itemGap: { |
|||
type: Number, |
|||
default: 16, |
|||
}, |
|||
}, |
|||
data() { |
|||
return { |
|||
leftItems: [], |
|||
rightItems: [], |
|||
}; |
|||
}, |
|||
watch: { |
|||
items: { |
|||
handler(newItems) { |
|||
this.calculateLayout(newItems); |
|||
}, |
|||
immediate: true, |
|||
deep: true, |
|||
}, |
|||
}, |
|||
mounted() { |
|||
this.calculateLayout(this.items); |
|||
}, |
|||
methods: { |
|||
// 获取列的实际高度(通过DOM查询) |
|||
getColumnHeight(columnRef) { |
|||
if (!columnRef) return 0; |
|||
const query = uni.createSelectorQuery().in(this); |
|||
return new Promise((resolve) => { |
|||
query.select(columnRef).boundingClientRect((data) => { |
|||
resolve(data ? data.height : 0); |
|||
}).exec(); |
|||
}); |
|||
}, |
|||
export default { |
|||
name: "WaterfallLayout", |
|||
props: { |
|||
// 数据源 |
|||
items: { |
|||
type: Array, |
|||
default: () => [], |
|||
}, |
|||
// 列数(固定为2列) |
|||
columnCount: { |
|||
type: Number, |
|||
default: 2, |
|||
}, |
|||
// 列间距(rpx) |
|||
columnGap: { |
|||
type: Number, |
|||
default: 16, |
|||
}, |
|||
// 项目间距(rpx) |
|||
itemGap: { |
|||
type: Number, |
|||
default: 16, |
|||
}, |
|||
}, |
|||
data() { |
|||
return { |
|||
leftItems: [], |
|||
rightItems: [], |
|||
}; |
|||
}, |
|||
watch: { |
|||
items: { |
|||
handler(newItems) { |
|||
this.calculateLayout(newItems); |
|||
}, |
|||
immediate: true, |
|||
deep: true, |
|||
}, |
|||
}, |
|||
mounted() { |
|||
this.calculateLayout(this.items); |
|||
}, |
|||
methods: { |
|||
// 获取列的实际高度(通过DOM查询) |
|||
getColumnHeight(columnRef) { |
|||
if (!columnRef) return 0; |
|||
const query = uni.createSelectorQuery().in(this); |
|||
return new Promise((resolve) => { |
|||
query.select(columnRef).boundingClientRect((data) => { |
|||
resolve(data ? data.height : 0); |
|||
}).exec(); |
|||
}); |
|||
}, |
|||
|
|||
// 计算布局 |
|||
calculateLayout(items) { |
|||
if (!items || !items.length) { |
|||
this.leftItems = []; |
|||
this.rightItems = []; |
|||
return; |
|||
} |
|||
|
|||
// 计算布局 |
|||
calculateLayout(items) { |
|||
if (!items || !items.length) { |
|||
this.leftItems = []; |
|||
this.rightItems = []; |
|||
return; |
|||
} |
|||
// 清空现有数据 |
|||
this.leftItems = []; |
|||
this.rightItems = []; |
|||
|
|||
// 清空现有数据 |
|||
this.leftItems = []; |
|||
this.rightItems = []; |
|||
// 逐个添加项目 |
|||
for (let i = 0; i < items.length; i++) { |
|||
this.addItem(items[i]); |
|||
} |
|||
}, |
|||
|
|||
// 逐个添加项目 |
|||
for (let i = 0; i < items.length; i++) { |
|||
this.addItem(items[i]); |
|||
} |
|||
}, |
|||
// 添加单个项目到合适的列 |
|||
addItem(item) { |
|||
// 简单的交替分配逻辑:比较两列的项目数量 |
|||
if (this.leftItems.length <= this.rightItems.length) { |
|||
this.leftItems.push(item); |
|||
} else { |
|||
this.rightItems.push(item); |
|||
} |
|||
}, |
|||
|
|||
// 添加单个项目到合适的列 |
|||
addItem(item) { |
|||
// 简单的交替分配逻辑:比较两列的项目数量 |
|||
if (this.leftItems.length <= this.rightItems.length) { |
|||
this.leftItems.push(item); |
|||
} else { |
|||
this.rightItems.push(item); |
|||
} |
|||
}, |
|||
// 清空所有项目 |
|||
clearItems() { |
|||
this.leftItems = []; |
|||
this.rightItems = []; |
|||
this.$emit("items-cleared"); |
|||
}, |
|||
|
|||
// 清空所有项目 |
|||
clearItems() { |
|||
this.leftItems = []; |
|||
this.rightItems = []; |
|||
this.$emit("items-cleared"); |
|||
}, |
|||
// 处理项目点击 |
|||
handleItemClick(item) { |
|||
this.$emit("item-click", item); |
|||
}, |
|||
|
|||
// 处理项目点击 |
|||
handleItemClick(item) { |
|||
this.$emit("item-click", item); |
|||
}, |
|||
// 获取所有项目 |
|||
getAllItems() { |
|||
return [...this.leftItems, ...this.rightItems]; |
|||
}, |
|||
|
|||
// 获取所有项目 |
|||
getAllItems() { |
|||
return [...this.leftItems, ...this.rightItems]; |
|||
}, |
|||
// 移除项目 |
|||
removeItem(itemId) { |
|||
// 从左列移除 |
|||
let index = this.leftItems.findIndex(item => item.id === itemId); |
|||
if (index !== -1) { |
|||
this.leftItems.splice(index, 1); |
|||
this.$emit("item-removed", itemId); |
|||
return; |
|||
} |
|||
|
|||
// 移除项目 |
|||
removeItem(itemId) { |
|||
// 从左列移除 |
|||
let index = this.leftItems.findIndex(item => item.id === itemId); |
|||
if (index !== -1) { |
|||
this.leftItems.splice(index, 1); |
|||
this.$emit("item-removed", itemId); |
|||
return; |
|||
} |
|||
|
|||
// 从右列移除 |
|||
index = this.rightItems.findIndex(item => item.id === itemId); |
|||
if (index !== -1) { |
|||
this.rightItems.splice(index, 1); |
|||
this.$emit("item-removed", itemId); |
|||
} |
|||
}, |
|||
}, |
|||
}; |
|||
// 从右列移除 |
|||
index = this.rightItems.findIndex(item => item.id === itemId); |
|||
if (index !== -1) { |
|||
this.rightItems.splice(index, 1); |
|||
this.$emit("item-removed", itemId); |
|||
} |
|||
}, |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.waterfall-layout { |
|||
width: 100%; |
|||
box-sizing: border-box; |
|||
} |
|||
.waterfall-layout { |
|||
width: 100%; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.waterfall-container { |
|||
display: flex; |
|||
gap: 16rpx; |
|||
padding: 0 20rpx; |
|||
box-sizing: border-box; |
|||
} |
|||
.waterfall-container { |
|||
display: flex; |
|||
gap: 16rpx; |
|||
padding: 0 20rpx; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.column { |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16rpx; |
|||
} |
|||
.column { |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16rpx; |
|||
} |
|||
|
|||
.waterfall-item { |
|||
box-sizing: border-box; |
|||
border-radius: 12rpx; |
|||
background: #fff; |
|||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08); |
|||
overflow: hidden; |
|||
transition: transform 0.2s ease; |
|||
} |
|||
.waterfall-item { |
|||
box-sizing: border-box; |
|||
border-radius: 12rpx; |
|||
background: #fff; |
|||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08); |
|||
overflow: hidden; |
|||
transition: transform 0.2s ease; |
|||
} |
|||
|
|||
.waterfall-item:active { |
|||
transform: scale(0.98); |
|||
} |
|||
.waterfall-item:active { |
|||
transform: scale(0.98); |
|||
} |
|||
|
|||
.item-image { |
|||
width: 100%; |
|||
height: 476rpx; |
|||
object-fit: cover; |
|||
} |
|||
.item-image { |
|||
width: 100%; |
|||
height: 476rpx; |
|||
object-fit: cover; |
|||
} |
|||
|
|||
.item-content { |
|||
padding: 16rpx; |
|||
} |
|||
.item-content { |
|||
padding: 16rpx; |
|||
} |
|||
|
|||
.item-title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
line-height: 1.3; |
|||
margin-bottom: 12rpx; |
|||
display: -webkit-box; |
|||
-webkit-box-orient: vertical; |
|||
-webkit-line-clamp: 2; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
.item-title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
line-height: 1.3; |
|||
margin-bottom: 12rpx; |
|||
display: -webkit-box; |
|||
-webkit-box-orient: vertical; |
|||
-webkit-line-clamp: 2; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.item-desc { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
line-height: 1.4; |
|||
margin-bottom: 16rpx; |
|||
display: -webkit-box; |
|||
-webkit-box-orient: vertical; |
|||
-webkit-line-clamp: 2; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
.item-desc { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
line-height: 1.4; |
|||
margin-bottom: 16rpx; |
|||
display: -webkit-box; |
|||
-webkit-box-orient: vertical; |
|||
-webkit-line-clamp: 2; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.item-tags { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 8rpx; |
|||
margin-bottom: 16rpx; |
|||
} |
|||
.item-tags { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 8rpx; |
|||
margin-bottom: 16rpx; |
|||
} |
|||
|
|||
.tag { |
|||
padding: 4rpx 12rpx; |
|||
background: #f5f5f5; |
|||
color: #666; |
|||
font-size: 20rpx; |
|||
border-radius: 12rpx; |
|||
white-space: nowrap; |
|||
} |
|||
.tag { |
|||
padding: 4rpx 12rpx; |
|||
background: #f5f5f5; |
|||
color: #666; |
|||
font-size: 20rpx; |
|||
border-radius: 12rpx; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.item-footer { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-top: 16rpx; |
|||
} |
|||
.item-footer { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-top: 16rpx; |
|||
} |
|||
|
|||
.user-info { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12rpx; |
|||
} |
|||
.user-info { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12rpx; |
|||
} |
|||
|
|||
.user-avatar { |
|||
width: 32rpx; |
|||
height: 32rpx; |
|||
border-radius: 50%; |
|||
} |
|||
.user-avatar { |
|||
width: 32rpx; |
|||
height: 32rpx; |
|||
border-radius: 50%; |
|||
} |
|||
|
|||
.username { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
} |
|||
.username { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
} |
|||
|
|||
.like-info { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6rpx; |
|||
} |
|||
.like-info { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6rpx; |
|||
} |
|||
|
|||
.like-icon { |
|||
font-size: 24rpx; |
|||
color: #ff6b6b; |
|||
} |
|||
.like-icon { |
|||
font-size: 24rpx; |
|||
color: #ff6b6b; |
|||
} |
|||
|
|||
.like-count { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
} |
|||
</style> |
|||
.like-count { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
} |
|||
</style> |
@ -1,8 +1,45 @@ |
|||
<template> |
|||
<view class="content"> |
|||
<GPT :agentId="agentId"></GPT> |
|||
</view> |
|||
</template> |
|||
|
|||
|
|||
<script> |
|||
import GPT from '@/components/GPT/index.vue' |
|||
export default { |
|||
components: { |
|||
GPT, |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
agentId:1 |
|||
} |
|||
}, |
|||
onLoad(option) { |
|||
const app = getApp(); |
|||
const bgMusic = app.globalData.bgMusic; |
|||
bgMusic.pause(); |
|||
if(option){ |
|||
this.agentId = option.id |
|||
} |
|||
}, |
|||
onLaunch: options => { |
|||
this.options = options |
|||
}, |
|||
onShow() { |
|||
|
|||
console.log('【init message connect type------>】', ); |
|||
}, |
|||
methods: { |
|||
}, |
|||
} |
|||
</script> |
|||
|
|||
<style scoped lang="scss"> |
|||
.content { |
|||
height: 100vh; |
|||
} |
|||
|
|||
<style> |
|||
</style> |
|||
</style> |
|||
|
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 1.8 KiB |