flat: 优化语音

This commit is contained in:
史典卓
2025-04-16 14:24:06 +08:00
parent 0d2b8ae65f
commit 446b48ef6d
28 changed files with 1059 additions and 264 deletions

View File

@@ -102,43 +102,36 @@ const initWaveBars = () => {
// 更新波形显示
const updateWaveform = () => {
if (!props.isActive) return;
// 如果没有传入音频数据,则使用模拟数据
const AMPLIFY = 1.6; // 振幅放大
const center = centerIndex.value;
// 如果没有传入音频数据,则使用模拟数据(加强振幅)
const audioData =
props.audioData.length > 0
? props.audioData
: Array(centerIndex.value + 1)
? props.audioData.map((v) => Math.min(v * AMPLIFY, 1))
: Array(center + 1)
.fill(0)
.map(() => Math.random() * 0.5 + 0.2);
// 从中间向两侧处理
for (let i = 0; i <= centerIndex.value; i++) {
// 左侧条索引
const leftIndex = centerIndex.value - i;
// 右侧条索引
const rightIndex = centerIndex.value + i;
.map(() => Math.random() * 0.7 + 0.3); // 模拟值更明显
// 获取音频数据值 (归一化到0-1)
for (let i = 0; i <= center; i++) {
const leftIndex = center - i;
const rightIndex = center + i;
const value = audioData[i] || 0;
// 更新左侧条
if (leftIndex >= 0) {
updateWaveBar(leftIndex, value);
}
// 更新右侧条(避免重复更新中心条)
if (leftIndex >= 0) updateWaveBar(leftIndex, value);
if (rightIndex < waveBars.value.length && rightIndex !== leftIndex) {
updateWaveBar(rightIndex, value);
}
}
// 继续动画
animationId = requestAnimationFrame(updateWaveform);
};
// 更新单个波形条
const updateWaveBar = (index, value) => {
// 动态高度 (4rpx到200rpx之间)
const height = 2 + value * 98;
// 动态高度 (4rpx到42rpx之间)
const height = 2 + value * 38;
// // 动态颜色
// let color;
// if (props.isCanceling) {

View File

@@ -1,116 +1,114 @@
<template>
<view class="chat-container">
<FadeView :show="!messages.length" :duration="600">
<view class="chat-background">
<image class="backlogo" src="/static/icon/backAI.png"></image>
<view class="back-rowTitle">欢迎使用青岛AI智能求职</view>
<view class="back-rowText">
我可以根据您的简历和求职需求帮你精准匹配青岛市互联网招聘信息对比招聘信息的优缺点提供面试指导等请把你的任务交给我吧~
</view>
<view class="back-rowh3">猜你所想</view>
<view class="back-rowmsg" v-for="item in queries" @click="sendMessageGuess(item)">
{{ item }}
<view class="chat-background" v-fade:600="!messages.length">
<image class="backlogo" src="/static/icon/backAI.png"></image>
<view class="back-rowTitle">欢迎使用青岛AI智能求职</view>
<view class="back-rowText">
我可以根据您的简历和求职需求帮你精准匹配青岛市互联网招聘信息对比招聘信息的优缺点提供面试指导等请把你的任务交给我吧~
</view>
<view class="back-rowh3">猜你所想</view>
<view class="back-rowmsg" v-for="item in queries" @click="sendMessageGuess(item)">
{{ item }}
</view>
<view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view>
</view>
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
<view class="chat-list list-content" v-fade:600="messages.length >= 1">
<view
v-for="(msg, index) in messages"
:key="index"
:id="'msg-' + index"
class="chat-item"
:class="{ self: msg.self }"
>
<view class="message" v-if="msg.self">
<view class="msg-filecontent" v-if="msg.files.length">
<view
class="msg-files btn-light"
v-for="(file, vInex) in msg.files"
:key="vInex"
@click="jumpUrl(file)"
>
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
<text class="msg-file-text">{{ file.name || '附件' }}</text>
</view>
</view>
{{ msg.displayText }}
</view>
<view class="message" :class="{ messageNull: !msg.displayText }" v-else>
<!-- {{ msg.displayText }} -->
<view class="message-markdown">
<md-render
:content="msg.displayText"
:typing="isTyping && messages.length - 1 === index"
></md-render>
<view class="message-controll" v-show="showControll(index)">
<view class="controll-left">
<image
class="controll-icon btn-light"
src="/static/icon/copy.png"
@click="copyMarkdown(msg.displayText)"
></image>
<image
class="controll-icon mar_le10 btn-light"
src="/static/icon/feedback.png"
v-if="!msg.userBadFeedback"
@click="userGoodFeedback(msg)"
></image>
<image
v-if="isSpeaking && !isPaused && speechIndex === index"
class="controll-icon mar_le10 btn-light"
src="/static/icon/stop.png"
@click="stopMarkdown(msg.displayText, index)"
></image>
<image
class="controll-icon mar_le10 btn-light"
src="/static/icon/broadcast.png"
@click="readMarkdown(msg.displayText, index)"
v-else
></image>
</view>
<view class="controll-right">
<image
class="controll-icon mar_ri10 btn-light"
src="/static/icon/refresh.png"
@click="refreshMarkdown(index)"
></image>
</view>
</view>
</view>
<!-- guess -->
<view
class="guess"
v-if="showGuess && !msg.self && messages.length - 1 === index && msg.displayText"
>
<view class="gulist">
<view
class="guess-list"
@click="sendMessageGuess(item)"
v-for="(item, index) in guessList"
:key="index"
>
{{ item }}
</view>
</view>
</view>
</view>
</view>
<view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view>
</view>
</FadeView>
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
<FadeView :show="messages.length >= 1" :duration="600">
<view class="chat-list list-content">
<view
v-for="(msg, index) in messages"
:key="index"
:id="'msg-' + index"
class="chat-item"
:class="{ self: msg.self }"
>
<view class="message" v-if="msg.self">
<view class="msg-filecontent" v-if="msg.files.length">
<view
class="msg-files btn-light"
v-for="(file, vInex) in msg.files"
:key="vInex"
@click="jumpUrl(file)"
>
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
<text class="msg-file-text">{{ file.name || '附件' }}</text>
</view>
</view>
{{ msg.displayText }}
</view>
<view class="message" :class="{ messageNull: !msg.displayText }" v-else>
<!-- {{ msg.displayText }} -->
<view class="message-markdown">
<md-render
:content="msg.displayText"
:typing="isTyping && messages.length - 1 === index"
></md-render>
<view class="message-controll" v-show="showControll(index)">
<view class="controll-left">
<image
class="controll-icon btn-light"
src="/static/icon/copy.png"
@click="copyMarkdown(msg.displayText)"
></image>
<image
class="controll-icon mar_le10 btn-light"
src="/static/icon/feedback.png"
@click="userGoodFeedback(msg)"
></image>
<image
v-if="isSpeaking && !isPaused && speechIndex === index"
class="controll-icon mar_le10 btn-light"
src="/static/icon/stop.png"
@click="stopMarkdown(msg.displayText, index)"
></image>
<image
class="controll-icon mar_le10 btn-light"
src="/static/icon/broadcast.png"
@click="readMarkdown(msg.displayText, index)"
v-else
></image>
</view>
<view class="controll-right">
<image
class="controll-icon mar_ri10 btn-light"
src="/static/icon/refresh.png"
@click="refreshMarkdown(index)"
></image>
</view>
</view>
</view>
<!-- guess -->
<view
class="guess"
v-if="showGuess && !msg.self && messages.length - 1 === index && msg.displayText"
>
<view class="gulist">
<view
class="guess-list"
@click="sendMessageGuess(item)"
v-for="(item, index) in guessList"
:key="index"
>
{{ item }}
</view>
</view>
</view>
</view>
</view>
<view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view>
<view v-if="isTyping" class="self">
<text class="message msg-loading">
<span class="ai-loading"></span>
</text>
</view>
<view v-if="isTyping" class="self">
<text class="message msg-loading">
<span class="ai-loading"></span>
</text>
</view>
</FadeView>
</view>
</scroll-view>
<view class="vio_container" @click="handleTouchEnd" :class="status" v-if="status !== 'idle'">
<!-- 使用 v-show 保证不销毁事件 -->
<view class="vio_container" :class="status" v-show="status !== 'idle'">
<view class="record-tip">{{ statusText }}</view>
<WaveDisplay
:background="audiowaveStyle"
@@ -119,7 +117,7 @@
:showInfo="isRecording"
/>
</view>
<view class="input-area" v-else>
<view class="input-area" v-show="status === 'idle'">
<view class="areatext">
<input
v-model="textInput"
@@ -133,7 +131,7 @@
/>
<view
class="input_vio"
@touchstart="handleTouchStart"
@touchstart.prevent="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
@@ -222,28 +220,42 @@
</scroll-view>
</view>
</view>
<PopupFeeBack ref="feeback" @onSend="confirmFeeBack"></PopupFeeBack>
<MsgTips ref="feeBackTips" content="已收到反馈,感谢您的关注" title="反馈成功" :icon="successIcon"></MsgTips>
</view>
</template>
<script setup>
import { ref, inject, nextTick, defineProps, defineEmits, onMounted, toRaw, reactive, computed } from 'vue';
import {
ref,
inject,
nextTick,
defineProps,
defineEmits,
onMounted,
onUnmounted,
toRaw,
reactive,
computed,
watch,
} from 'vue';
import { storeToRefs } from 'pinia';
import config from '@/config.js';
import useChatGroupDBStore from '@/stores/userChatGroupStore';
import MdRender from '@/components/md-render/md-render.vue';
import CollapseTransition from '@/components/CollapseTransition/CollapseTransition.vue';
import FadeView from '@/components/FadeView/FadeView.vue';
import PopupFeeBack from './popupbadFeeback.vue';
import AudioWave from './AudioWave.vue';
import WaveDisplay from './WaveDisplay.vue';
import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue';
import { useSpeechReader } from '@/hook/useSpeechReader';
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
// 全局
const { $api, navTo, throttle } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
import successIcon from '@/static/icon/success.png';
// hook
const {
isRecording,
@@ -256,7 +268,7 @@ const {
lastFinalText,
} = useAudioRecorder(config.vioceBaseURl);
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useSpeechReader();
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio, audioUrl } = useTTSPlayer(config.speechSynthesis);
// state
const queries = ref([]);
@@ -272,7 +284,11 @@ const startY = ref(0);
const cancelThreshold = 100;
const speechIndex = ref(0);
const isAudioPermission = ref(false);
const feebackData = ref(null);
// ref for DOM element
const voiceBtn = ref(null);
const feeback = ref(null);
const feeBackTips = ref(null);
const state = reactive({
uploadFileTips: '请根据以上附件,帮我推荐岗位。',
});
@@ -510,7 +526,6 @@ const handleTouchStart = async (e) => {
return tipsPermisson();
}
cancelAudio();
console.log('handleTouchStart');
startY.value = e.touches[0].clientY;
status.value = 'recording';
showfile.value = false;
@@ -521,7 +536,6 @@ const handleTouchStart = async (e) => {
};
const handleTouchMove = (e) => {
console.log('handleTouchMove');
const moveY = e.touches[0].clientY;
if (startY.value - moveY > cancelThreshold) {
status.value = 'cancel';
@@ -531,28 +545,23 @@ const handleTouchMove = (e) => {
};
const handleTouchEnd = () => {
console.log('handleTouchEnd');
if (status.value === 'cancel') {
console.log('取消发送');
cancelRecording();
} else {
console.log('stopRecording');
stopRecording();
$api.sleep(1000).then(() => {
if (isAudioPermission.value) {
if (recognizedText.value) {
sendMessage(recognizedText.value);
} else {
$api.msg('说话时长太短');
}
if (isAudioPermission.value) {
if (recognizedText.value) {
sendMessage(recognizedText.value);
} else {
$api.msg('说话时长太短');
}
});
}
}
status.value = 'idle';
};
const handleTouchCancel = () => {
console.log('handleTouchCancel');
stopRecording();
status.value = 'idle';
};
@@ -578,9 +587,18 @@ function copyMarkdown(value) {
}
function userGoodFeedback(msg) {
$api.msg('该功能正在开发中,敬请期待后续更新!');
console.log(msg.dataId);
// useChatGroupDBStore().badFeedback(msg.dataId, msg.parentGroupId);
// $api.msg('该功能正在开发中,敬请期待后续更新!');
feeback.value?.open();
feebackData.value = msg;
}
function confirmFeeBack(value) {
useChatGroupDBStore()
.badFeedback(feebackData.value, value)
.then(() => {
feeback.value?.close();
feeBackTips.value?.open();
});
}
function readMarkdown(value, index) {
@@ -788,14 +806,14 @@ image-margin-top = 40rpx
-webkit-user-select: text;
.message-markdown
border-radius: 0 20rpx 20rpx 20rpx;
padding: 20rpx 20rpx 0 20rpx;
padding: 20rpx 20rpx 20rpx 20rpx;
background: #F6F6F6;
.message-controll
display: flex
justify-content: space-between
align-items: center
border-top: 2rpx solid #EAEAEA
padding: 24rpx 0
padding: 24rpx 0 4rpx 0
margin-top: 10rpx
.controll-left
.controll-right
@@ -870,12 +888,19 @@ image-margin-top = 40rpx
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */
.input_vio:active
background: #e8e8e8
.vio_container
background: transparent
padding: 28rpx
text-align: center
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */
.record-tip
font-weight: 400;
color: #909090;

View File

@@ -1,8 +1,197 @@
<template>
<uni-popup ref="popup" type="bottom" borderRadius="12px 12px 0 0" background-color="#F6F6F6">
<view class="feeback">
<view class="titile">反馈</view>
<view class="pop-h3">针对问题</view>
<view class="pop-content">
<view
class="item"
:class="{ active: item.check }"
@click="toggleCheck(item.id)"
v-for="item in wt"
:key="item.id"
>
{{ item.label }}
</view>
</view>
<view class="pop-h3">针对回答</view>
<view class="pop-content">
<view
class="item"
:class="{ active: item.check }"
@click="toggleCheck(item.id)"
v-for="item in hd"
:key="item.id"
>
{{ item.label }}
</view>
</view>
<view class="pop-h3">我要补充</view>
<view class="supplement">
<textarea v-model="inputText" cols="30" rows="10"></textarea>
</view>
<view class="btn button-click" @click="send">提交</view>
<view class="close-btn" @click="close"></view>
</view>
</uni-popup>
</template>
<script>
<script setup>
import { ref, inject, defineEmits } from 'vue';
const emit = defineEmits(['onSend']);
const { $api } = inject('globalFunction');
const popup = ref(null);
const inputText = ref('');
const wt = ref([
{ label: '上下文错误', id: 1, check: false },
{ label: '理解错误', id: 2, check: false },
{ label: '未识别问题中的错误', id: 3, check: false },
]);
const hd = ref([
{ label: '违法有害', id: 11, check: false },
{ label: '内容不专业', id: 12, check: false },
{ label: '推理错误', id: 13, check: false },
{ label: '计算错误', id: 14, check: false },
{ label: '内容不完整', id: 15, check: false },
{ label: '事实错误', id: 16, check: false },
]);
function open() {
resetCheck();
inputText.value = '';
popup.value.open();
}
function close() {
popup.value.close();
}
function send() {
const text = getLabel();
if (text) {
emit('onSend', text);
// close();
} else {
$api.msg('清输入反馈内容');
}
}
function getLabel() {
const wtArr = wt.value.filter((item) => item.check).map((item) => item.label);
const hdArr = hd.value.filter((item) => item.check).map((item) => item.label);
let str = '';
wtArr.length ? (str += `问题:${wtArr.join(',')}. `) : '';
hdArr.length ? (str += `回答:${hdArr.join(',')}. `) : '';
inputText.value ? (str += `描述:${inputText.value}. `) : '';
return str;
}
function resetCheck() {
wt.value = wt.value.map((item) => {
return { ...item, check: false }; // 创建新对象,确保更新响应式
});
hd.value = hd.value.map((item) => {
return { ...item, check: false }; // 创建新对象,确保更新响应式
});
}
function toggleCheck(id) {
if (id < 10) {
const newWt = wt.value.map((item) => {
if (item.id === id) {
return { ...item, check: !item.check };
}
return item;
});
wt.value = newWt;
} else {
const newHd = hd.value.map((item) => {
if (item.id === id) {
return { ...item, check: !item.check };
}
return item;
});
hd.value = newHd;
}
}
defineExpose({ open, close });
</script>
<style>
</style>
<style lang="stylus" scoped>
.feeback
padding: 38rpx 32rpx;
.titile
font-weight: 500;
font-size: 36rpx;
color: #333333;
line-height: 42rpx;
text-align: center;
margin-bottom: 20rpx;
.pop-h3
font-weight: 600;
font-size: 32rpx;
color: #000000;
line-height: 38rpx;
text-align: left;
padding: 8rpx 0
margin-top: 32rpx
.pop-content
.item
width: fit-content;
height: 80rpx;
background: #E8EAEE;
border-radius: 12rpx 12rpx 12rpx 12rpx;
text-align: center;
line-height: 80rpx;
padding: 0 36rpx
border: 2rpx solid transparent;
display: inline-block
margin-right: 28rpx
margin-top: 28rpx
.active
border: 2rpx solid #256BFA;
color: #256BFA;
.supplement
height: 200rpx;
background: #FFFFFF;
border-radius: 20rpx 20rpx 20rpx 20rpx;
margin-top: 28rpx
padding: 20rpx 24rpx
.btn
height: 90rpx;
background: #256BFA;
border-radius: 12rpx 12rpx 12rpx 12rpx;
font-weight: 500;
font-size: 32rpx;
color: #FFFFFF;
line-height: 90rpx;
text-align: center;
margin-top: 62rpx
.close-btn
position: absolute;
right: 32rpx;
top: 32rpx;
width: 56rpx;
height: 56rpx;
.close-btn::before
position: absolute;
left: 50%;
top: 50%;
content: '';
width: 4rpx;
height: 28rpx;
border-radius: 2rpx
background: #5A5A68;
transform: translate(50%, -50%) rotate(-45deg) ;
.close-btn::after
position: absolute;
left: 50%;
top: 50%;
content: '';
width: 4rpx;
height: 28rpx;
border-radius: 2rpx
background: #5A5A68;
transform: translate(50%, -50%) rotate(45deg)
</style>