增加中断,重新回复,反馈功能

main
xushilin 4 months ago
parent 3ca236b933
commit bcabae8067

@ -1,7 +1,7 @@
import request from '@/request'; import request from '@/request';
// 获取汇总 // 获取回复
export const getAIResponse = (data) => request({ export const getAIResponse = (data) => request({
method : 'POST', method : 'POST',
url: '/api/chat', url: '/api/chat',

@ -1,185 +1,381 @@
<template> <template>
<view class="chat"> <view class="chat">
<view <view v-for="(m,index) in messages" :key="m.id" :id="'msg-' + m.id" :class="['msg', m.role]">
v-for="m in messages" <view v-if="m.role === 'user'" class="bubble user-bubble">
:key="m.id" <text v-if="m.inputType === 'text'">{{ m.content }}</text>
:id="'msg-' + m.id" <view class="text-voice" v-if="m.inputType === 'voice'" @tap="playVoice(m)">
:class="['msg', m.role]" <text>{{ m.duration }}</text>
> <image class="voice-play" src="@/static/voice-play.png" mode="widthFix"></image>
<view v-if="m.role === 'user'" class="bubble user-bubble"> </view>
<text v-if="m.inputType === 'text'">{{ m.content }}</text> </view>
<view <view v-else class="bubble ai-bubble">
class="text-voice" <view v-if="m.type === 'card'" class="ai-card">
v-if="m.inputType === 'voice'" <view class="ai-card-title">{{ m.title }}</view>
@tap="playVoice(m)" <view class="ai-card-body">{{ m.content }}</view>
> </view>
<text>{{ m.duration }}</text> <view v-else-if="m.loading" class="ai-loading">
<image <view class="loading-dot"></view>
class="voice-play" <view class="loading-dot"></view>
src="@/static/voice-play.png" <view class="loading-dot"></view>
mode="widthFix" </view>
></image> <view v-else>
</view> <text>{{
</view>
<view v-else class="bubble ai-bubble">
<view v-if="m.type === 'card'" class="ai-card">
<view class="ai-card-title">{{ m.title }}</view>
<view class="ai-card-body">{{ m.content }}</view>
</view>
<view v-else-if="m.loading" class="ai-loading">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
<view v-else>
<text>{{
m.displayText !== undefined ? m.displayText : m.content m.displayText !== undefined ? m.displayText : m.content
}}</text> }}</text>
</view> </view>
</view> </view>
</view>
</view> <view class="continue-create" v-if="m.isBreak && index === messages.length - 1">
<view class="text" @tap="continueCreate">
继续生成
</view>
<!-- upvote-highlight -->
</view>
<view class="tool-box" v-if="!isReplying && index === messages.length - 1 && m.role === 'assistant'">
<image class="tool-image" src="@/static/refresh.png" mode="widthFix" @tap="refresh"></image>
<image class="tool-image"
:src="isHighLight ? (upvoteIndex === 0 ? upvoteHighLightImage : upvoteImage) : upvoteImage"
mode="widthFix" @tap="upvote"></image>
<image class="tool-image rote"
:src="isHighLight ? (upvoteIndex === 1 ? upvoteHighLightImage : upvoteImage) : upvoteImage"
mode="widthFix" @tap="unUpvote"></image>
</view>
</view>
<uni-popup ref="popup" type="bottom" class="popup" @change="changeShow">
<view class="feedback">
<view class="top">
<view class="title">反馈</view>
<view class="close" @tap="closeFeedback">×</view>
</view>
<view class="quick-ask">
<view :class="['ask',item.id === askActive ? 'active' : '']" v-for="item in quickAskList" :key="item.id" @tap="selectAsk(item.id)">{{item.label}}</view>
</view>
<view>
<textarea class="textarea" placeholder="我们想知道你对此回答不满意的原因,你认为更好的回答是什么?"></textarea>
</view>
<button type="primary" style="font-size: 16px;" @tap="submitFeedback"></button>
</view>
</uni-popup>
</view>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
messages: { messages: {
type: Array, type: Array,
default() { default () {
return []; return [];
}, },
}, },
}, isReplying: {
methods: { type: Boolean,
playVoice(voicePath) { default: false
console.log('voicePath',voicePath); }
// if (!voicePath) { },
// uni.showToast({ data() {
// title: "", return {
// icon: "none", upvoteImage: require('@/static/upvote.png'),
// }); upvoteHighLightImage: require('@/static/upvote-highlight.png'),
// return; isHighLight: false,
// } upvoteIndex: null,
// if (!this.innerAudioContext) { quickAskList : [
// this.innerAudioContext = uni.createInnerAudioContext(); {
// this.innerAudioContext.autoplay = false; id : 1,
// this.innerAudioContext.onError(() => { label : '虚假信息'
// uni.showToast({ },
// title: "", {
// icon: "none", id : 2,
// }); label : '没有帮助'
// }); },
// } {
// try { id : 3,
// this.innerAudioContext.stop(); label : '其他'
// } catch (e) {} }
// this.innerAudioContext.src = voicePath; ],
// this.innerAudioContext.play(); askActive : null
}, }
}, },
}; methods: {
changeShow(e) {
this.$emit('changeShow',e.show)
},
selectAsk(id){
this.askActive = id;
},
continueCreate() {
this.$emit('continueCreate')
},
refresh() {
this.$emit('refresh')
},
upvote() {
if (this.upvoteIndex !== 0) {
this.isHighLight = true
} else {
this.isHighLight = !this.isHighLight;
}
this.upvoteIndex = 0;
if (this.isHighLight) {
uni.showToast({
title: '反馈成功',
icon: 'none',
duration: 1500
})
}
},
unUpvote() {
if (this.upvoteIndex !== 1) {
this.$refs.popup.open();
}
if (!this.isHighLight) {
this.$refs.popup.open();
}else{
this.isHighLight = !this.isHighLight;
}
this.upvoteIndex = 1;
},
submitFeedback(){
this.$refs.popup.close();
this.isHighLight = true;
uni.showToast({
title: '反馈成功',
icon: 'none',
duration: 1500
})
},
closeFeedback(){
this.$refs.popup.close();
this.isHighLight = false;
},
playVoice(voicePath) {
console.log('voicePath', voicePath);
// if (!voicePath) {
// uni.showToast({
// title: "",
// icon: "none",
// });
// return;
// }
// if (!this.innerAudioContext) {
// this.innerAudioContext = uni.createInnerAudioContext();
// this.innerAudioContext.autoplay = false;
// this.innerAudioContext.onError(() => {
// uni.showToast({
// title: "",
// icon: "none",
// });
// });
// }
// try {
// this.innerAudioContext.stop();
// } catch (e) {}
// this.innerAudioContext.src = voicePath;
// this.innerAudioContext.play();
},
},
};
</script> </script>
<style scoped> <style scoped lang="scss">
.chat { .chat {
margin: 6px 0 12px; margin: 6px 0 12px;
} }
.msg { .msg {
/* margin: 10px 0; */ /* margin: 10px 0; */
display: flex; display: flex;
margin-bottom: 10px; margin-bottom: 10px;
padding-bottom: 10px; padding-bottom: 10px;
} flex-wrap: wrap;
}
.msg.user {
justify-content: flex-end; .msg.user {
} justify-content: flex-end;
}
.bubble {
max-width: 80%; .bubble {
padding: 10px 12px; max-width: 80%;
border-radius: 14px; padding: 10px 12px;
font-size: 14px; border-radius: 14px;
line-height: 1.5; font-size: 14px;
} line-height: 1.5;
}
.user-bubble {
background: #4e7bff; .user-bubble {
color: #fff; background: #4e7bff;
border-bottom-right-radius: 4px; color: #fff;
margin-right: 6px; border-bottom-right-radius: 4px;
} margin-right: 6px;
}
.ai-bubble {
background: #fff; .ai-bubble {
color: #333; background: #fff;
border-bottom-left-radius: 4px; color: #333;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); border-bottom-left-radius: 4px;
} box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.listen-btn {
margin-left: 8px; .continue-create {
color: #6b7280; width: 100%;
font-size: 14px;
} .text {
font-size: 12px;
.ai-card-title { border: 1px solid #ddd;
color: #5f6fff; border-radius: 20px;
font-weight: 600; display: flex;
margin-bottom: 6px; align-items: center;
} justify-content: center;
width: 90px;
.ai-card-body { padding: 5px 0;
color: #666; margin-top: 8px;
} }
}
/* loading animation */
.ai-loading { .tool-box {
display: flex; width: 100%;
align-items: center; margin-top: 15px;
gap: 6px; padding-left: 5px;
padding: 4px 0; display: flex;
} align-items: center;
.loading-dot { .tool-image {
width: 8px; width: 14px;
height: 8px; margin-right: 15px;
border-radius: 50%; }
background: #9ca3af;
animation: loading-bounce 1.5s ease-in-out infinite both; .rote {
} transform: rotate(180deg);
}
.loading-dot:nth-child(1) { }
animation-delay: -0.32s;
} .listen-btn {
margin-left: 8px;
.loading-dot:nth-child(2) { color: #6b7280;
animation-delay: -0.16s; font-size: 14px;
} }
@keyframes loading-bounce { .ai-card-title {
0%, color: #5f6fff;
80%, font-weight: 600;
100% { margin-bottom: 6px;
transform: scale(0.8); }
opacity: 0.5;
} .ai-card-body {
color: #666;
40% { }
transform: scale(1.2);
opacity: 1; /* loading animation */
} .ai-loading {
} display: flex;
align-items: center;
.text-voice { gap: 6px;
display: flex; padding: 4px 0;
align-items: center; }
}
.loading-dot {
.voice-play { width: 8px;
width: 20px; height: 8px;
margin-left: 5px; border-radius: 50%;
} background: #9ca3af;
</style> animation: loading-bounce 1.5s ease-in-out infinite both;
}
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loading-bounce {
0%,
80%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1.2);
opacity: 1;
}
}
.text-voice {
display: flex;
align-items: center;
}
.voice-play {
width: 20px;
margin-left: 5px;
}
.popup {
z-index: 99999;
}
.feedback {
background-color: #fff;
padding: 0 20px;
border-radius: 10px 10px 0 0;
padding-bottom: 20px;
.top {
position: relative;
text-align: center;
padding: 10px 0;
.title {
font-weight: bold;
}
.close {
background-color: #ddd;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font-size: 18px;
}
}
.quick-ask{
display: flex;
.ask{
padding: 5px 15px;
border: 1px solid #ddd;
border-radius: 10px;
margin-right: 10px;
font-size: 14px;
}
.active{
background-color: #007AFF;
color: #fff;
}
}
.textarea {
width: 100%;
background-color: rgba(45, 44, 46, .05);
// min-height: 60px;
padding: 10px 12px;
box-sizing: border-box;
margin-bottom: 20px;
margin-top: 10px;
border-radius: 10px;
font-size: 14px;
}
}
</style>

@ -4,7 +4,7 @@
<top @clickLeft="openDrawer"></top> <top @clickLeft="openDrawer"></top>
<scroll-view class="content" :scroll-y="true" show-scrollbar="false" scroll-with-animation ref="scrollView"> <scroll-view class="content" :scroll-y="true" show-scrollbar="false" scroll-with-animation ref="scrollView">
<front @onSuggestionTap="onQuickAsk" /> <front @onSuggestionTap="onQuickAsk" />
<chat :messages="messages" /> <chat :messages="messages" @continueCreate="continueCreate" :isReplying="isReplying" @refresh="refresh" @changeShow="changeShow"/>
</scroll-view> </scroll-view>
<view :style="{height: marginBottom + 'px',backgroundColor : '#fff'}" /> <view :style="{height: marginBottom + 'px',backgroundColor : '#fff'}" />
<leftDrawer :historyGroups="historyGroups" ref="popup" @changeShow="changeShow" <leftDrawer :historyGroups="historyGroups" ref="popup" @changeShow="changeShow"
@ -26,9 +26,6 @@
import chat from "./chat/index.vue"; import chat from "./chat/index.vue";
import leftDrawer from "./leftDrawer/index.vue"; import leftDrawer from "./leftDrawer/index.vue";
import search from "./search/index.vue"; import search from "./search/index.vue";
import {
recognizeAudio
} from "@/utils/uploadVoice.js";
export default { export default {
components: { components: {
top, top,
@ -47,12 +44,14 @@
show: false, show: false,
marginBottom: 0, marginBottom: 0,
isReplying: false, isReplying: false,
breakReplying: false breakReplying: false,
replyData: {},
isRefresh: false
}; };
}, },
mounted() { mounted() {
this.loadChatHistory(); this.loadChatHistory();
this.scrollToBottom(); // this.scrollToBottom();
let self = this; let self = this;
uni.onKeyboardHeightChange((res) => { uni.onKeyboardHeightChange((res) => {
uni.pageScrollTo({ uni.pageScrollTo({
@ -73,6 +72,29 @@
this.typewriterTimers = {}; this.typewriterTimers = {};
}, },
methods: { methods: {
//
refresh() {
this.messages.splice(this.messages.length - 1, 1);
this.inputText = this.messages[this.messages.length - 1].content;
this.isRefresh = true;
this.onSend();
},
//
continueCreate() {
this.breakReplying = false;
this.isReplying = true;
if (!this.isLoading) {
this.messages[this.messages.length - 1].isBreak = false;
this.replyAction(this.replyData, true)
} else {
this.messages.splice(this.messages.length - 1, 1);
this.messages.push({
id: this.loadingId,
role: "assistant",
loading: true,
});
}
},
// //
handleBreak() { handleBreak() {
if (this.isLoading) { if (this.isLoading) {
@ -84,11 +106,12 @@
id: replyId, id: replyId,
role: "assistant", role: "assistant",
type: "text", type: "text",
content: '已中断回复 ', content: '',
displayText: "", displayText: "已停止",
isBreak: true
}); });
this.isReplying = false; this.isReplying = false;
this.isLoading = false; this.scrollToBottom();
} }
this.breakReplying = true; this.breakReplying = true;
}, },
@ -196,21 +219,22 @@
}, },
// //
async onSend(inputType = "text", inputContent = "", duration = undefined) { async onSend(inputType = "text", inputContent = "", duration = undefined) {
if(this.isReplying) return; if (this.isReplying) return;
const text = (this.inputText || "").trim(); const text = (this.inputText || "").trim();
if (!text || this.isLoading) return; if (!text || this.isLoading) return;
this.baseId = Date.now(); this.baseId = Date.now();
// 1. if (!this.isRefresh) {
this.messages.push({ // 1.
id: this.baseId, this.messages.push({
role: "user", id: this.baseId,
type: "text", role: "user",
content: text, type: "text",
inputType: typeof inputType === "string" ? inputType : "text", content: text,
inputContent, inputType: typeof inputType === "string" ? inputType : "text",
duration, inputContent,
}); duration,
});
}
// 2. loading // 2. loading
this.loadingId = this.baseId + 0.5; this.loadingId = this.baseId + 0.5;
this.messages.push({ this.messages.push({
@ -219,19 +243,24 @@
loading: true, loading: true,
}); });
this.scrollToBottom(); this.scrollToBottom();
this.isReplying = true;
this.inputText = ""; this.inputText = "";
this.isReplying = true;
this.isLoading = true; this.isLoading = true;
this.isRefresh = false;
this.addToHistory(text); this.addToHistory(text);
// 3. AI // 3. AI
const reply = await getAIResponse({ const reply = await getAIResponse({
message: text, message: text,
}); });
if(this.breakReplying) { this.replyData = reply;
this.isLoading = false;
if (this.breakReplying) {
this.breakReplying = false; this.breakReplying = false;
return; return;
} }
this.isLoading = false; this.replyAction(reply)
},
replyAction(reply, isCreate) {
let content = '' let content = ''
if (reply.errMsg) { if (reply.errMsg) {
content = `请求出错: ${reply.errMsg}` content = `请求出错: ${reply.errMsg}`
@ -243,16 +272,18 @@
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1); if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
// 5. + // 5. +
const replyId = this.baseId + 1; const replyId = this.baseId + 1;
this.messages.push({ if (!isCreate) {
id: replyId, this.messages.push({
role: "assistant", id: replyId,
type: "text", role: "assistant",
content, type: "text",
displayText: "", content,
}); displayText: "",
this.$nextTick(() => this.scrollToBottom()); });
}
this.$nextTick(() => this.scrollToBottom());
this.typewriter(replyId, content); this.typewriter(replyId, content);
}, },
// //
typewriter(messageId, fullText) { typewriter(messageId, fullText) {
@ -262,7 +293,7 @@
if (this.typewriterTimers[messageId]) { if (this.typewriterTimers[messageId]) {
clearInterval(this.typewriterTimers[messageId]); clearInterval(this.typewriterTimers[messageId]);
} }
let index = 0; let index = msg.displayText.length;
msg.displayText = fullText.substring(0, index + 1); msg.displayText = fullText.substring(0, index + 1);
index += 1; index += 1;
const speed = 50; // 50ms const speed = 50; // 50ms
@ -271,6 +302,7 @@
if (this.breakReplying) { if (this.breakReplying) {
clearInterval(timer); clearInterval(timer);
delete this.typewriterTimers[messageId]; delete this.typewriterTimers[messageId];
msg.isBreak = true;
this.isReplying = false; this.isReplying = false;
this.breakReplying = false this.breakReplying = false
this.isLoading = false; this.isLoading = false;
@ -285,7 +317,10 @@
// 使 // 使
msg.displayText = fullText; msg.displayText = fullText;
this.isReplying = false; this.isReplying = false;
this.breakReplying = false this.breakReplying = false;
this.$nextTick(() => {
this.scrollToBottom();
})
} }
}, speed); }, speed);
this.typewriterTimers[messageId] = timer; this.typewriterTimers[messageId] = timer;
@ -337,8 +372,4 @@
box-sizing: border-box; box-sizing: border-box;
} }
/*
::v-deep .drawer-scroll {
padding-top: 44px;
} */
</style> </style>

@ -5,9 +5,9 @@
background-color="#fff" background-color="#fff"
type="left" type="left"
class="history-popup" class="history-popup"
@change="changeShow" @change="changeShow"
> >
<view class="drawer-mask"> <view class="drawer-mask" :style="{paddingTop : statusBarHeight + 'px'}">
<view class="drawer"> <view class="drawer">
<scroll-view class="drawer-scroll" scroll-y show-scrollbar="false"> <scroll-view class="drawer-scroll" scroll-y show-scrollbar="false">
<view v-for="g in historyGroups" :key="g.date" class="drawer-group"> <view v-for="g in historyGroups" :key="g.date" class="drawer-group">
@ -26,7 +26,7 @@
</scroll-view> </scroll-view>
<view class="drawer-footer"> <view class="drawer-footer">
<view class="user-icon">👤</view> <view class="user-icon">👤</view>
<text class="user-name">用户<EFBFBD> </text> <text class="user-name">用户</text>
<view class="footer-gear" @tap="clearAllHistory"></view> <view class="footer-gear" @tap="clearAllHistory"></view>
</view> </view>
</view> </view>
@ -45,6 +45,14 @@ export default {
} }
} }
}, },
data () {
return {
statusBarHeight : 0
}
},
async mounted(){
this.statusBarHeight = uni.getSystemInfoSync().statusBarHeight / 2;
},
methods : { methods : {
onHistoryItemTap(text){ onHistoryItemTap(text){
this.$emit('onHistoryItemTap',text) this.$emit('onHistoryItemTap',text)
@ -157,4 +165,8 @@ export default {
width: 24px; width: 24px;
text-align: center; text-align: center;
} }
// ::v-deep .drawer-scroll {
// }
</style> </style>

@ -145,6 +145,7 @@
} }
uni.showLoading({ uni.showLoading({
title: "识别中...", title: "识别中...",
mask : true
}); });
const text = await recognizeAudio(res.tempFilePath); const text = await recognizeAudio(res.tempFilePath);
if (!text?.trim()) { if (!text?.trim()) {

@ -46,18 +46,17 @@ const request = (config) => {
}) })
.catch((error) => { .catch((error) => {
let { message } = error; let { message } = error;
// if (message === "Network Error") {
if (message === "Network Error") { // message = "网络不稳定,请检查网络 ";
message = "网络不稳定,请检查网络 "; // } else if (error.errMsg === "request:fail") {
} else if (error.errMsg === "request:fail") { // message = "后端接口连接异常";
message = "后端接口连接异常"; // } else if (error.errMsg === "request:fail timeout") {
} else if (error.errMsg === "request:fail timeout") { // message = "系统接口请求超时";
message = "系统接口请求超时"; // } else if (message.includes("Request failed with status code")) {
} else if (message.includes("Request failed with status code")) { // message = "系统接口" + message.substr(message.length - 3) + "异常";
message = "系统接口" + message.substr(message.length - 3) + "异常"; // } else {
} else { // message = `抱歉,出了点问题: ${error.errMsg || error.message}`;
message = `抱歉,出了点问题: ${error.errMsg || error.message}`; // }
}
resolve(error); resolve(error);
}); });
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -10,7 +10,7 @@ export const recognizeAudio = async (tempFilePath) => {
}); });
const uploadRes = await new Promise((resolve, reject) => { const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({ uni.uploadFile({
url: `http://192.168.1.18:9022/recognize_speech`, url: `http://192.168.1.25:9022/recognize_speech`,
filePath: tempFilePath, filePath: tempFilePath,
name: "speech", name: "speech",
formData: { formData: {

Loading…
Cancel
Save