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

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,24 +1,11 @@
<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"
:key="m.id"
:id="'msg-' + m.id"
:class="['msg', m.role]"
>
<view v-if="m.role === 'user'" class="bubble user-bubble"> <view v-if="m.role === 'user'" class="bubble user-bubble">
<text v-if="m.inputType === 'text'">{{ m.content }}</text> <text v-if="m.inputType === 'text'">{{ m.content }}</text>
<view <view class="text-voice" v-if="m.inputType === 'voice'" @tap="playVoice(m)">
class="text-voice"
v-if="m.inputType === 'voice'"
@tap="playVoice(m)"
>
<text>{{ m.duration }}</text> <text>{{ m.duration }}</text>
<image <image class="voice-play" src="@/static/voice-play.png" mode="widthFix"></image>
class="voice-play"
src="@/static/voice-play.png"
mode="widthFix"
></image>
</view> </view>
</view> </view>
<view v-else class="bubble ai-bubble"> <view v-else class="bubble ai-bubble">
@ -37,23 +24,134 @@
}}</text> }}</text>
</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>
</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: {
type: Boolean,
default: false
}
},
data() {
return {
upvoteImage: require('@/static/upvote.png'),
upvoteHighLightImage: require('@/static/upvote-highlight.png'),
isHighLight: false,
upvoteIndex: null,
quickAskList : [
{
id : 1,
label : '虚假信息'
},
{
id : 2,
label : '没有帮助'
},
{
id : 3,
label : '其他'
}
],
askActive : null
}
}, },
methods: { 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) { playVoice(voicePath) {
console.log('voicePath',voicePath); console.log('voicePath', voicePath);
// if (!voicePath) { // if (!voicePath) {
// uni.showToast({ // uni.showToast({
// title: "", // title: "",
@ -78,88 +176,123 @@ export default {
// this.innerAudioContext.play(); // 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 { .msg.user {
justify-content: flex-end; justify-content: flex-end;
} }
.bubble { .bubble {
max-width: 80%; max-width: 80%;
padding: 10px 12px; padding: 10px 12px;
border-radius: 14px; border-radius: 14px;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
} }
.user-bubble { .user-bubble {
background: #4e7bff; background: #4e7bff;
color: #fff; color: #fff;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
margin-right: 6px; margin-right: 6px;
} }
.ai-bubble { .ai-bubble {
background: #fff; background: #fff;
color: #333; color: #333;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
} }
.continue-create {
width: 100%;
.text {
font-size: 12px;
border: 1px solid #ddd;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
width: 90px;
padding: 5px 0;
margin-top: 8px;
}
}
.listen-btn { .tool-box {
width: 100%;
margin-top: 15px;
padding-left: 5px;
display: flex;
align-items: center;
.tool-image {
width: 14px;
margin-right: 15px;
}
.rote {
transform: rotate(180deg);
}
}
.listen-btn {
margin-left: 8px; margin-left: 8px;
color: #6b7280; color: #6b7280;
font-size: 14px; font-size: 14px;
} }
.ai-card-title { .ai-card-title {
color: #5f6fff; color: #5f6fff;
font-weight: 600; font-weight: 600;
margin-bottom: 6px; margin-bottom: 6px;
} }
.ai-card-body { .ai-card-body {
color: #666; color: #666;
} }
/* loading animation */ /* loading animation */
.ai-loading { .ai-loading {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 0; padding: 4px 0;
} }
.loading-dot { .loading-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #9ca3af; background: #9ca3af;
animation: loading-bounce 1.5s ease-in-out infinite both; animation: loading-bounce 1.5s ease-in-out infinite both;
} }
.loading-dot:nth-child(1) { .loading-dot:nth-child(1) {
animation-delay: -0.32s; animation-delay: -0.32s;
} }
.loading-dot:nth-child(2) { .loading-dot:nth-child(2) {
animation-delay: -0.16s; animation-delay: -0.16s;
} }
@keyframes loading-bounce {
@keyframes loading-bounce {
0%, 0%,
80%, 80%,
100% { 100% {
@ -171,15 +304,78 @@ export default {
transform: scale(1.2); transform: scale(1.2);
opacity: 1; opacity: 1;
} }
} }
.text-voice { .text-voice {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.voice-play { .voice-play {
width: 20px; width: 20px;
margin-left: 5px; 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> </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,10 +219,11 @@
}, },
// //
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();
if (!this.isRefresh) {
// 1. // 1.
this.messages.push({ this.messages.push({
id: this.baseId, id: this.baseId,
@ -210,7 +234,7 @@
inputContent, inputContent,
duration, 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,6 +272,7 @@
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;
if (!isCreate) {
this.messages.push({ this.messages.push({
id: replyId, id: replyId,
role: "assistant", role: "assistant",
@ -250,9 +280,10 @@
content, content,
displayText: "", 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>

@ -7,7 +7,7 @@
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