| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716 |
- <template>
- <view class="chat-container" :style="{
- paddingTop: appStore.navbarHeight + 'px',
- }">
- <view class="mine_ybt_title flex-center bg_color_fff"
- :style="{ height: appStore.navbarHeight + 'px',
- paddingTop: appStore.statusBarHeight + 'px'}"
- >
- <!-- <text class="font_size35 bold">AI客服</text> -->
- <agentCheck ref="agentCheckRef"></agentCheck>
- </view>
- <!-- 聊天消息区域 -->
- <view class="chat-messages flex-column">
- <!-- 工具栏 -->
- <tools @addHuiHuaFn="addHuiHuaFn"></tools>
- <!-- 消息列表 -->
- <scroll-view
- class="scrollViewRef flex_1"
- scroll-y="true"
- :scroll-top="scrollTop"
- ref="scrollViewRef"
- :scroll-with-animation="true"
- @refresherrefresh="handlePullDownRefresh"
- :refresher-enabled="true"
- :refresher-triggered="triggered"
- >
- <messagesInfoDefault v-if="messages.length==0"
- @sendMessage="sendMessage"
- ref="messagesInfoDefaultRef" key="messagesInfoDefaultRef">
- </messagesInfoDefault>
- <messagesInfo
- :messages="messages"
- :isLoading="isLoading"
- @imageLoaded="scrollToBottom"
- :id="`msg-${messagesKey}`">
- </messagesInfo>
- </scroll-view>
- </view>
- <!-- 输入区域 -->
- <view class="input-area">
- <button class="image-btn" :disabled="isLoading" @click.stop.prevent="uploadImage">
- <image class="image-icon" src="/static/img/service/tupian.png" model="aspectFit"></image>
- </button>
- <button v-if="isvoice"
- class="record-btn"
- :class="{ recording: isRecording }"
- @touchstart="startRecord"
- @touchend="stopRecord"
- @touchmove="handleTouchMove"
- @touchcancel="cancelRecord"
- :disabled="isRecording && isCancel"
- >
- <view v-if="!isRecording">按住说话</view>
- <view v-if="isRecording && !isCancel">松手发送,上移取消</view>
- <view v-if="isRecording && isCancel">松手取消</view>
- <text v-if="recordDuration>0"></text>
- </button>
- <input v-else
- class="input-box"
- :placeholder="isLoading?'努力回答中...':'发消息或按住说话'"
- placeholder-style="color: #999"
- v-model.trim="inputText"
- :disabled="isLoading"
- @confirm="sendMessage({chatType:0,msgContent:inputText})"
- />
- <button class="isvoice-btn" @click.stop.prevent="isvoice=!isvoice;authorizeRecord()" :disabled="isLoading">
- <image class="mic-icon" src="/static/img/service/xiaoxi.png" v-if="isvoice" model="aspectFit"></image>
- <image class="mic-icon" src="/static/img/service/maikefengyuyin.png" v-else model="aspectFit"></image>
- </button>
- <button class="send-btn" @click.stop.prevent="sendMessage({chatType:0,msgContent:inputText})" :disabled="isLoading" v-if="!isvoice">
- <image class="send-icon" src="/static/img/service/send-icon.png"></image>
- </button>
- <view class="ai-tip">内容由AI生成,仅供参考</view>
- </view>
- <!-- 录音动画/提示 -->
- <view
- class="record-toast"
- v-if="isRecording"
- :class="{ cancel: isCancel }"
- >
- <image
- v-if="!isCancel"
- src="/static/img/voice/recording.gif"
- class="toast-icon"
- alt="录音中动画"
- ></image>
- <image
- v-if="isCancel"
- src="/static/img/voice/cancel.png"
- class="toast-icon"
- alt="取消录音图标"
- ></image>
- <text v-if="!isCancel">正在录音...({{60-recordDuration}})</text>
- <text v-if="isCancel">松手取消发送</text>
- </view>
- </view>
- </template>
- <script setup>
- import { ref, nextTick, watch } from 'vue';
- import { onShow,onHide,onLoad } from "@dcloudio/uni-app"
- import { chatHistoryDetails } from '@/api/ai.js';
- import messagesInfo from "./components/messagesInfo.vue";
- import messagesInfoDefault from "./components/messagesInfoDefault.vue";
- import tools from "./components/tools.vue";
- import agentCheck from "./components/agentCheck.vue";
- import {
- HTTP_REQUEST_URL,
- HTTP_REQUEST_URL_WS,
- TOKENNAME
- } from '@/config/app';
- import { chooseImageOne,checkLoginShowModal,checkAiQuotaDailyModal,getUserInfo } from "@/utils/util.js";
- // 封装的websocket
- import WSClient from '@/utils/wsUtil.js';
- import { useAppStore } from '@/stores/app'
- import { useToast } from '@/hooks/useToast'
- import dayjs from "dayjs";
- const appStore = useAppStore();
- const { Toast } = useToast();
- const agentCheckRef = ref(null);
- const messagesInfoDefaultRef = ref(null);
- const newSession = ref(false);//是否新会话
- const historySession = ref('');//是否新会话
- // 添加滚动相关变量
- const scrollTimeout = ref(null);
- const scrollViewRef = ref(null);
- const scrollTop = ref(0);
- const messagesKey = ref(0);
- const triggered = ref(false);
- // 底部发送功能
- const inputText = ref('');
- const isLoading = ref(false);//思考中
- const isvoice = ref(false);//是否语音输入
- const wsClient = ref(null);
- // 模拟聊天消息数据
- const messages = ref([]);
- watch(() => appStore.agentId, (state) => {
- messages.value = [];
- getAdSearchFn()
- });
- onLoad((e)=>{
- console.log('ai onLoad',e);
- })
- onShow(async()=>{
- if(!await checkLoginShowModal())return;
- nextTick(async ()=>{
- // 初始化选择智能体
- await agentCheckRef.value.initAgentId();
- // 初始化完成后获取 agentId
- console.log('agentId:', agentCheckRef.value.agentId);
- if(appStore.msgContent){
- sendMessage({chatType:0,msgContent:appStore.msgContent});
- appStore.UPDATE_msgContent('');
- }else if(appStore.sessionId){
- handlePullDownRefresh();
- }else{
- // getAdSearchFn()
- }
-
- })
- aiStartChatFn();
- });
- onHide(()=>{
- isLoading.value = false;
- cleanupResources();
- });
- // 新增:添加回话功能
- function addHuiHuaFn(){
- newSession.value = true;
- messages.value = [];
- getAdSearchFn();
- }
- function getAdSearchFn(){
- nextTick(()=>{
- setTimeout(()=>{
- messagesInfoDefaultRef.value.getAdSearchFn();
- },50)
- })
- }
- function authorizeRecord() {
- uni.authorize({
- scope: 'scope.record',
- success() {
- // uni.getRecorderManager();
- }
- })
- }
- function aiStartChatFn(){
- cleanupResources();
- wsClient.value = new WSClient({
- url: `${HTTP_REQUEST_URL_WS}/api/websocket`,
- method: 'POST',
- headers: {
- "Authorization": 'Bearer '+appStore.token
- }
- });
- // 注册SSE事件
- registerEvents();
- wsClient.value.open({type:0,message:inputText.value})
- }
- //
- async function sendMessage({chatType,msgContent=""}){
- console.log('sendMessage',wsClient.value);
- // 登录检测
- if(!await checkLoginShowModal())return;
- // 今日免费提问次数检测
- if(!await checkAiQuotaDailyModal())return;
- if (!msgContent) {
- Toast({title:'请输入内容'});
- return;
- }
- isLoading.value = true
- messages.value.push({
- msgContent,
- chatType,// 0 (文本),1 (图片)
- speakerType:0//0用户消息1AI消息
- });
- messages.value.push({
- msgContent: "",
- event: "start",//sse长链接的返回状态
- chatType,
- speakerType:1,
- messageTimeNow:dayjs().format('HH:mm')
- });
- //type 0 (文本),1 (图片)
- wsClient.value.send({
- type:chatType,
- message:msgContent,
- agentId:agentCheckRef.value.agentId,
- useBalance:true,
- newSession:newSession.value,
- historySession:historySession.value
- });
- messagesKey.value++;
- inputText.value = '';
- newSession.value = false;
- // 每次收到新消息时滚动到底部
- // 如果是图片,子组件里图片加载成功后触发
- if(chatType!=1)scrollToBottom();
- }
- async function uploadImage(){
- if(!await checkLoginShowModal())return;
- const res = await chooseImageOne();
- console.log('uploadImage',res);
- if(res){
- // userInfo.value[key] = res.fileName
- sendMessage({chatType:1,msgContent:res.data})
- }
- }
- function cleanupResources(closeSSE=true) {
- // 关闭SSE连接
- if (wsClient.value) {
- // 移除所有事件监听
- if (wsClient.value.callbacks) {
- Object.keys(wsClient.value.callbacks).forEach(event => {
- wsClient.value.callbacks[event] = [];
- });
- }
- wsClient.value.close('clean');
- wsClient.value = null;
- }
- }
- function registerEvents(){
- wsClient.value.on('open', (data) => {
- console.log('ws连接成功',data);
- });
- wsClient.value.on('answer', (data) => {
- console.log('answer',data.segment,messages.value.length - 1);
- isLoading.value = false;
- const lastIndex = messages.value.length - 1;
- // 保存完整内容(累加片段)
- // const newFullAnswer = messages.value[lastIndex].msgContent + data.segment;
- messages.value[lastIndex].msgContent = messages.value[lastIndex].msgContent + data.segment;
- messagesKey.value++;
- console.log('answer',messages);
- // 每次收到新消息时滚动到底部
- scrollToBottom();
- nextTick(()=>{
- // 获取用户信息
- getUserInfo();
- })
- });
- wsClient.value.on('error', (err) => {
- isLoading.value = false;
- console.log('error',err);
- // aiStartChatFn();
- Toast({ title: JSON.stringify(err) || "服务器异常" });
- });
- wsClient.value.on('close', (data) => {
- isLoading.value = false;
- let lastIndex = messages.value.length - 1;
- console.log('close',data,lastIndex);
- // 判断关闭原因,取消,删除当前聊天内容,超时提示请求超时
- switch (data.reason) {
- case 'timeout': {
- // 提示超时
- // uni.showToast({
- // title: '请求超时,请重试',
- // icon: 'none'
- // });
- if(lastIndex>-1)messages.value[lastIndex].msgContent = messages.value[lastIndex].msgContent || "请求超时,请重试";
- aiStartChatFn();
- break;
- }
- default:
- break;
- }
- });
- }
- // 新增:滚动到底部的方法
- function scrollToBottom() {
- nextTick(() => {
- // if (scrollTimeout.value) return;
- // scrollTimeout.value = setTimeout(() => {
- uni.createSelectorQuery()
- .select(`#msg-${messagesKey.value}`)
- .boundingClientRect(rect => {
- if (rect) {
- scrollTop.value = rect.height;
- // 使用更平滑的滚动方式
- uni.pageScrollTo({
- scrollTop: rect.bottom,
- duration: 100
- })
- }
- })
- .exec();
- // scrollTimeout.value = null;
- // }, 80); // 延长节流间隔至80ms,减少冲突
- });
- }
- /*******************************************历史数据********************************************************/
- const hitstoryParams = ref({
- pageNum: 0,
- pageSize: 10,
- sessionId:appStore.sessionId,
- });
- // 重命名方法,避免与小程序生命周期冲突
- function handlePullDownRefresh() {
- console.log('下拉刷新触发');
- triggered.value = true;
- hitstoryParams.value.pageNum++;
- chatHistoryDetailsFn();
- }
- function chatHistoryDetailsFn(){
- hitstoryParams.value.sessionId = appStore.sessionId;
- chatHistoryDetails(hitstoryParams.value).then(res=>{
- triggered.value = false;
- console.log('chatHistoryFn',res);
- const rows = res?.rows || [];
- if(rows.length==0 && hitstoryParams.value.pageNum>0){
- hitstoryParams.value.pageNum--;
- // Toast({title:'没有更多历史记录了'});
- return;
- }
- if(messages.value.length==res.total){
- Toast({title:'没有更多历史记录了'});
- return;
- }
- messages.value = [...rows, ...messages.value]
- })
- }
- /*******************************************语音功能********************************************************/
- const isRecording = ref(false);// 是否正在录音
- const isCancel = ref(false); // 是否取消录音
- const recordDuration = ref(0); // 录音时长(秒)
- const tempFilePath = ref(''); // 录音临时文件路径
- const recordTimer = ref(null); // 录音计时定时器
- const recorderManager = uni.getRecorderManager(); // 录音管理
- // 开始录音(触摸开始)
- const startRecord = async (e) => {
- if(!await checkLoginShowModal())return;
- // 防止冒泡导致的异常
- e.stopPropagation();
-
- // 初始化录音参数
- const options = {
- format: 'mp3', // 录音格式
- sampleRate: 44100, // 采样率
- numberOfChannels: 1, // 声道数
- encodeBitRate: 96000 // 编码比特率
- };
- // 开始录音
- recorderManager.start(options);
- isRecording.value = true;
- isCancel.value = false;
- recordDuration.value = 0;
- // 计时逻辑
- recordTimer.value = setInterval(() => {
- recordDuration.value++;
- // 限制最大录音时长(如60秒)
- if (recordDuration.value >= 60) {
- stopRecord();
- }
- }, 1000);
- // 监听录音错误
- recorderManager.onError((err) => {
- console.error('录音错误:', err);
- cancelRecord();
- uni.showToast({ title: '录音失败', icon: 'none' });
- });
- };
- // 停止录音(触摸结束)
- const stopRecord = () => {
- if (!isRecording.value) return;
- // 清除计时
- clearInterval(recordTimer.value);
-
- // 停止录音
- recorderManager.stop();
- recorderManager.onStop((res) => {
- tempFilePath.value = res.tempFilePath;
- console.log('recorderManager',res.tempFilePath);
-
- // 判断是否取消或录音过短
- if (isCancel.value) {
- uni.showToast({ title: '已取消发送', icon: 'none' });
- } else if (recordDuration.value < 1) {
- uni.showToast({ title: '录音时间太短', icon: 'none' });
- } else {
- // 上传录音并添加到列表
- uploadVoice(res.tempFilePath);
- }
- // 重置状态
- resetRecordState();
- });
-
- };
- // 处理触摸移动(用于判断是否取消)
- const handleTouchMove = (e) => {
- if (!isRecording.value) return;
- // 获取按钮位置和触摸位置
- const buttonRect = uni.createSelectorQuery().select('.record-btn').boundingClientRect();
- buttonRect.exec((rects) => {
- const rect = rects[0];
- if (!rect) return;
- // 计算触摸点与按钮的垂直距离(向上移动超过50px视为取消)
- const touchY = e.touches[0].clientY;
- const buttonTop = rect.top;
- if (touchY < buttonTop - 50) {
- isCancel.value = true;
- } else {
- isCancel.value = false;
- }
- });
- };
- // 取消录音(触摸中断)
- const cancelRecord = () => {
- if (!isRecording.value) return;
-
- clearInterval(recordTimer.value);
- recorderManager.stop();
- resetRecordState();
- uni.showToast({ title: '已取消发送', icon: 'none' });
- };
- // 重置录音状态
- const resetRecordState = () => {
- isRecording.value = false;
- isCancel.value = false;
- recordDuration.value = 0;
- tempFilePath.value = '';
- };
- // 上传录音到服务器
- const uploadVoice = (filePath) => {
- if (!filePath) return;
- uni.showLoading({ title: '发送中...' });
-
- // 调用上传接口
- uni.uploadFile({
- url: `${HTTP_REQUEST_URL}/mini/chat/file/voiceUpload`, // 替换为你的后端接口
- filePath,
- name: 'file', // 后端接收文件的参数名
- formData: {
- duration: recordDuration.value // 携带录音时长
- },
- method: 'POST',
- header: {
- "Authorization": appStore.token
- },
- success: (res) => {
- console.log('上传结果:', res);
- const result = JSON.parse(res.data);
- if (result.code === 200) {
- // 上传成功,添加到消息列表
- sendMessage({ chatType:0,msgContent:result.data });
- // messages.value.push({
- // id: Date.now(),
- // isMine: true,
- // avatar: '/static/avatar/user.png',
- // duration: recordDuration.value,
- // url: result.data.url, // 服务器返回的音频地址
- // isPlaying: false
- // });
- } else {
- uni.showToast({ title: '发送失败', icon: 'none' });
- }
- },
- fail: (err) => {
- console.error('语音上传失败:', err);
- uni.showToast({ title: '发送失败', icon: 'none' });
- },
- complete: () => {
- uni.hideLoading();
- }
- });
- };
- </script>
- <style scoped lang="scss">
- .mine_ybt_title{
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- z-index: 1;
- }
- /* 录音区域 */
- .record-btn{
- flex: 1;
- height: 70rpx;
- line-height: 70rpx;
- font-size: 30rpx;
- color: #333;
- }
- .record-area {
- padding: 30rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .record-btn {
- flex: 1;
- height: 70rpx;
- line-height: 70rpx;
- font-size: 30rpx;
- color: #333;
- background-color: #f2f2f2;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- }
- .record-btn.recording {
- background-color: #ff4d4f;
- color: #fff;
- }
- .record-btn.cancel {
- background-color: #999;
- }
- /* 整体容器样式 */
- .chat-container {
- display: flex;
- flex-direction: column;
- height: 100vh;
- // padding-bottom: 200rpx;
- /* background-color: #f5f5f5; */
- }
- /* 聊天消息区域样式 */
- .chat-messages {
- flex: 1;
- overflow-y: auto;
- .scrollViewRef{
- // height: 100%;
- padding: 16rpx;
- overflow-y: auto;
- }
- // height: calc(100vh - 200rpx);
- }
- /* 输入区域样式 */
- .input-area {
- // position: fixed;
- // left: 0;
- // bottom: 0;
- // width: 100%;
- display: flex;
- align-items: center;
- background-color: #fff;
- padding: 30rpx 40rpx;
- border-top: 1rpx solid #e0e0e0;
- position: relative;
- }
- .ai-tip{
- position: absolute;
- bottom: 5rpx;
- width: calc(100% - 80rpx);
- text-align: center;
- font-size: 20rpx;
- color: #999;
- }
- /* 图片图标样式 */
- .image-btn{
- margin-right: 30rpx;
- width: 40rpx;
- height: 40rpx;
- &[disabled]{
- .image-icon{
- opacity: 0.4;
- }
- }
- .image-icon {
- width: 100%;
- height:100%;
- }
- }
- /* 输入框样式 */
- .input-box {
- flex: 1;
- height: 70rpx;
- font-size: 30rpx;
- color: #333;
- background-color: transparent;
- }
- /* 麦克风图标样式 */
- .isvoice-btn{
- width: 40rpx;
- height: 40rpx;
- margin: 0 40rpx;
- &[disabled]{
- .mic-icon{
- opacity: 0.4;
- }
- }
- .mic-icon {
- width: 100%;
- height:100%;
- }
- }
- /* 发送按钮样式 */
- .send-btn {
- width: 60rpx;
- height: 60rpx;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- &[disabled]{
- .send-icon{
- opacity: 0.4;
- }
- }
- }
- /* 发送图标样式 */
- .send-icon {
- width: 100%;
- height: 100%;
- }
- /* 录音提示 */
- .record-toast {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 300rpx;
- height: 300rpx;
- border-radius: 20rpx;
- background-color: rgba(0, 0, 0, 0.7);
- color: #fff;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- z-index: 999;
- }
- .record-toast.cancel {
- background-color: rgba(255, 77, 79, 0.8);
- }
- .toast-icon {
- width: 200rpx;
- height: 200rpx;
- margin-bottom: 20rpx;
- }
- </style>
|