Update to MiniCPM-o 2.6

This commit is contained in:
yiranyyu
2025-01-14 15:33:44 +08:00
parent b75a362dd6
commit 53c0174797
123 changed files with 16848 additions and 2952 deletions

View File

@@ -0,0 +1,3 @@
<template>
<div class="call-header"></div>
</template>

View File

@@ -0,0 +1,82 @@
<template>
<div class="time">
<div class="time-minute">{{ minute || '00' }}</div>
<div class="time-colon">:</div>
<div class="time-second">{{ second || '00' }}</div>
</div>
</template>
<script setup>
import { limitTime, tipsRemainingTime } from '@/enums';
const start = defineModel();
const emits = defineEmits(['timeUp']);
const remainingTime = ref();
const minute = ref();
const second = ref();
const timeInterval = ref(null);
const startCount = () => {
remainingTime.value = limitTime;
updateCountDown();
timeInterval.value = setInterval(() => {
updateCountDown();
}, 1000);
};
const updateCountDown = () => {
let minutes = Math.floor(remainingTime.value / 60);
let seconds = remainingTime.value % 60;
// 格式化分钟和秒,确保它们是两位数
minute.value = minutes < 10 ? '0' + minutes : minutes;
second.value = seconds < 10 ? '0' + seconds : seconds;
// 剩余1分钟提示用户
if (remainingTime.value === tipsRemainingTime) {
ElMessage({
type: 'warning',
message: `This call will disconnect in ${tipsRemainingTime} seconds.`,
duration: 3000,
customClass: 'time-warning'
});
}
// 防止倒计时变成负数
if (remainingTime.value > 0) {
remainingTime.value--;
} else {
clearInterval(timeInterval);
emits('timeUp');
}
};
watch(
() => start.value,
newVal => {
timeInterval.value && clearInterval(timeInterval.value);
if (newVal) {
startCount();
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.time {
display: flex;
align-items: center;
.time-minute,
.time-second {
width: 26px;
height: 26px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 3.848px;
background: rgba(47, 47, 47, 0.5);
}
.time-colon {
margin: 0 3px;
}
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<div class="delay-tips">
<span>当前发生延迟目前延迟{{ delayTimestamp }}ms积压{{ delayCount * 200 }}ms未发</span>
</div>
</template>
<script setup>
defineProps({
delayTimestamp: {
type: Number,
defalult: 0
},
delayCount: {
type: Number,
defalult: 0
}
});
</script>
<style lang="less" scoped>
.delay-tips {
font-size: 12px;
color: #dc3545;
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<div class="extra-info">
<div class="model-version" v-if="modelVersion">模型版本: {{ modelVersion }}</div>
<div class="web-version">前端版本: {{ webVersion }}</div>
</div>
</template>
<script setup>
defineProps({
modelVersion: {
type: String,
default: ''
},
webVersion: {
type: String,
default: ''
}
});
</script>
<style lang="less" scoped>
.extra-info {
position: fixed;
top: 62px;
left: 4vw;
display: flex;
.model-version,
.web-version {
font-size: 12px;
color: red;
}
.model-version {
margin-right: 16px;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="ideas">
<div class="ideas-title">
<img src="@/assets/images/ideas-icon.png " />
<span>Convsersation ideas</span>
</div>
<div class="ideas-content">
<div class="ideas-content-item" v-for="(item, index) in ideasList" :key="index">{{ item }}</div>
</div>
</div>
</template>
<script setup>
defineProps({
ideasList: {
type: Array,
default: () => []
}
});
</script>
<style lang="less" scoped>
.ideas {
margin-top: 16px;
box-shadow: 0 0 0 0.5px #e0e0e0;
border-radius: 12px;
padding: 18px 28px;
&-title {
font-size: 20px;
font-weight: 500;
margin-bottom: 20px;
display: flex;
align-items: center;
img {
width: 24px;
height: 24px;
margin-right: 10px;
}
span {
color: #171717;
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
}
&-content {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
&-item {
display: flex;
align-items: center;
border-radius: 10px;
background: #eaefff;
padding: 10px 24px;
color: #7579eb;
font-family: PingFang SC;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
}
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="like-box">
<div class="like-btn" @click="selectFeedbackStatus('like')">
<img v-if="feedbackStatus === '' || feedbackStatus === 'dislike'" src="@/assets/images/zan.png" />
<img v-else src="@/assets/images/zan-active.png" />
</div>
<div class="dislike-btn" @click="selectFeedbackStatus('dislike')">
<img v-if="feedbackStatus === '' || feedbackStatus === 'like'" src="@/assets/images/cai.png" />
<img v-else src="@/assets/images/cai-active.png" />
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="t('feedbackDialogTitle')"
width="400"
:align-center="true"
@close="cancelFeedback"
>
<el-input type="textarea" :rows="4" v-model="comment" />
<div class="operate-btn">
<el-button type="primary" :loading="submitLoading" @click="submitFeedback">确定</el-button>
<el-button @click="cancelFeedback">取消</el-button>
</div>
</el-dialog>
</template>
<script setup>
import { feedback } from '@/apis';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const feedbackStatus = defineModel('feedbackStatus');
const curResponseId = defineModel('curResponseId');
const dialogVisible = ref(false);
const comment = ref('');
const submitLoading = ref(false);
const selectFeedbackStatus = val => {
if (!curResponseId.value) {
return;
}
feedbackStatus.value = val;
dialogVisible.value = true;
};
// 提交反馈
const submitFeedback = async () => {
submitLoading.value = true;
const { code, message } = await feedback({
response_id: curResponseId.value,
rating: feedbackStatus.value,
comment: comment.value
});
submitLoading.value = false;
if (code !== 0) {
ElMessage({
type: 'error',
message: message,
duration: 3000,
customClass: 'system-error'
});
return;
}
ElMessage.success('反馈成功');
dialogVisible.value = false;
setTimeout(() => {
feedbackStatus.value = '';
}, 2000);
};
const cancelFeedback = () => {
dialogVisible.value = false;
feedbackStatus.value = '';
};
</script>
<style lang="less" scoped>
.like-box {
display: flex;
margin: 0 16px;
.like-btn,
.dislike-btn {
width: 26px;
height: 26px;
background: #f3f3f3;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
cursor: pointer;
&:hover {
background: #d1d1d1;
}
img {
width: 16px;
height: 16px;
}
}
.dislike-btn {
margin-left: 16px;
}
}
.operate-btn {
margin-top: 20px;
display: flex;
justify-content: flex-end;
.el-button--primary {
background: #647fff;
border-color: #647fff;
&:hover {
border-color: #647fff;
}
}
}
</style>

View File

@@ -0,0 +1,404 @@
<template>
<div class="user-config">
<div class="user-config-title">模型配置</div>
<div class="config-item">
<div class="config-item-label">语音打断</div>
<div class="config-item-content">
<el-switch
v-model="configData.canStopByVoice"
inline-prompt
active-text=""
inactive-text=""
size="small"
:disabled="isCalling"
/>
</div>
</div>
<div class="config-item">
<div class="config-item-label">视频画质</div>
<div class="config-item-content">
<el-radio-group v-model="configData.videoQuality" :disabled="isCalling">
<el-radio :value="true">高清</el-radio>
<el-radio :value="false">低清</el-radio>
</el-radio-group>
</div>
</div>
<div class="config-item">
<div class="config-item-label">VAD阈值</div>
<div class="config-item-content vad-slider">
<el-slider
v-model="configData.vadThreshold"
:min="0.5"
:max="1"
:step="0.1"
size="small"
:disabled="isCalling"
/>
</div>
</div>
<!-- <div class="timbre-model">
<div class="timbre-model-label">音色人物</div>
<div class="timbre-model-content">
<el-select
v-model="configData.timbreId"
style="width: 100%"
@change="handleChangePeople"
clearable
placeholder="请选择"
>
<el-option v-for="item in peopleList" :key="item.id" :value="item.id" :label="item.name">
{{ item.name }}
</el-option>
</el-select>
</div>
</div> -->
<div class="prompt-item">
<div class="prompt-item-label">Assistant_prompt</div>
<div class="prompt-item-content">
<el-input
type="textarea"
:rows="3"
v-model="configData.assistantPrompt"
resize="none"
:disabled="isCalling"
/>
</div>
</div>
<div class="config-item">
<div class="config-item-label">使用语音prompt</div>
<div class="config-item-content">
<el-switch
v-model="configData.useAudioPrompt"
inline-prompt
active-text=""
inactive-text=""
size="small"
:disabled="isCalling"
@change="handleSelectUseAudioPrompt"
/>
</div>
</div>
<div class="voice-prompt-box">
<div class="prompt-item" v-if="configData.useAudioPrompt">
<div class="prompt-item-label">Voice_clone_prompt</div>
<div class="prompt-item-content">
<el-input
type="textarea"
:rows="8"
v-model="configData.voiceClonePrompt"
resize="none"
:disabled="isCalling"
/>
</div>
</div>
<div class="timbre-config" v-if="configData.useAudioPrompt">
<div class="timbre-config-label">音色选择</div>
<div class="timbre-config-content">
<el-checkbox-group v-model="configData.timbre" @change="handleSelectTimbre" :disabled="isCalling">
<el-checkbox :value="1" label="Default Audio"></el-checkbox>
<el-upload
v-model:file-list="fileList"
action=""
:multiple="false"
:on-change="handleChangeFile"
:auto-upload="false"
:show-file-list="false"
:disabled="isCalling"
accept="audio/*"
>
<el-checkbox :value="2">
<!-- <span>Customization: Upload Audio</span> -->
<span>Customization</span>
<SvgIcon name="upload" className="checkbox-icon" />
</el-checkbox>
</el-upload>
</el-checkbox-group>
</div>
</div>
<div class="file-content" v-if="fileName">
<SvgIcon name="document" class="document-icon" />
<span class="file-name">{{ fileName }}</span>
</div>
</div>
</div>
</template>
<script setup>
const isCalling = defineModel('isCalling');
const type = defineModel('type');
let defaultVoiceClonePrompt =
'你是一个AI助手。你能接受视频音频和文本输入并输出语音和文本。模仿输入音频中的声音特征。';
let defaultAssistantPrompt = '作为助手,你将使用这种声音风格说话。';
const fileList = ref([]);
const fileName = ref('');
const configData = ref({
canStopByVoice: false,
videoQuality: false,
useAudioPrompt: true,
vadThreshold: 0.8,
voiceClonePrompt: defaultVoiceClonePrompt,
assistantPrompt: defaultAssistantPrompt,
timbre: [1],
audioFormat: 'mp3',
base64Str: '',
timbreId: ''
});
const peopleList = [
{
id: 1,
name: 'Trump',
voiceClonePrompt: '',
assistantPrompt: ''
},
{
id: 2,
name: '说相声',
voiceClonePrompt: '克隆音频提示中的音色以生成语音',
assistantPrompt: '请角色扮演这段音频,请以相声演员的口吻说话'
},
{
id: 3,
name: '默认',
voiceClonePrompt: defaultVoiceClonePrompt,
assistantPrompt: defaultAssistantPrompt
}
];
watch(
() => type.value,
val => {
if (val === 'video') {
console.log('val: ', val);
defaultVoiceClonePrompt =
'你是一个AI助手。你能接受视频音频和文本输入并输出语音和文本。模仿输入音频中的声音特征。';
defaultAssistantPrompt = '作为助手,你将使用这种声音风格说话。';
} else {
defaultVoiceClonePrompt = '克隆音频提示中的音色以生成语音。';
defaultAssistantPrompt = 'Your task is to be a helpful assistant using this voice pattern.';
}
configData.value.voiceClonePrompt = defaultVoiceClonePrompt;
configData.value.assistantPrompt = defaultAssistantPrompt;
},
{ immediate: true }
);
onMounted(() => {
handleSetStorage();
});
const handleSelectTimbre = e => {
if (e.length > 1) {
const val = e[e.length - 1];
configData.value.timbre = [val];
// 默认音色
if (val === 1) {
configData.value.audioFormat = 'mp3';
configData.value.base64Str = '';
fileList.value = [];
fileName.value = '';
}
}
};
const handleChangeFile = file => {
if (isAudio(file) && sizeNotExceed(file)) {
fileList.value = [file];
fileName.value = file.name;
configData.value.timbre = [2];
handleUpload();
} else {
ElMessage.error('Please upload audio file and size not exceed 10MB');
}
};
const isAudio = file => {
return file.raw.type.includes('audio');
};
const sizeNotExceed = file => {
return file.size / 1024 / 1024 <= 10;
};
const handleUpload = async () => {
const file = fileList.value[0].raw;
if (file) {
const reader = new FileReader();
reader.onload = e => {
const base64String = e.target.result.split(',')[1];
configData.value.audioFormat = file.name.split('.')[1];
configData.value.base64Str = base64String;
};
reader.readAsDataURL(file);
}
};
const handleSelectUseAudioPrompt = val => {
if (val) {
configData.value.voiceClonePrompt = defaultVoiceClonePrompt;
configData.value.assistantPrompt = defaultAssistantPrompt;
}
};
// 配置发生变化更新到localstorage中
watch(configData.value, () => {
handleSetStorage();
});
const handleSetStorage = () => {
const { timbre, canStopByVoice, ...others } = configData.value;
const defaultConfigData = {
canStopByVoice,
...others
};
localStorage.setItem('configData', JSON.stringify(defaultConfigData));
localStorage.setItem('canStopByVoice', canStopByVoice);
};
const handleChangePeople = val => {
console.log('val: ', val);
if (!val) {
return;
}
const index = peopleList.findIndex(item => item.id === val);
configData.value.voiceClonePrompt = peopleList[index].voiceClonePrompt;
configData.value.assistantPrompt = peopleList[index].assistantPrompt;
configData.value.timbre = [1];
};
</script>
<style lang="less">
.user-config {
&-title {
height: 61px;
padding: 18px 18px 0;
color: rgba(23, 23, 23, 0.9);
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.config-item {
display: flex;
align-items: center;
width: 100%;
padding: 0 0 0 18px;
margin-bottom: 12px;
&-label {
width: 120px;
flex-shrink: 0;
}
&-content {
flex: 1;
margin-left: 16px;
.el-radio-group {
.el-radio {
width: 50px;
}
}
}
&-content.vad-slider {
width: 80%;
padding-left: 7px;
margin-right: 20px;
.el-slider__button {
width: 14px;
height: 14px;
}
}
}
.timbre-config {
padding: 0 0 0 18px;
&-label {
margin-bottom: 12px;
}
&-content {
display: flex;
align-items: center;
.el-checkbox-group {
display: flex;
flex-wrap: wrap;
flex: 1;
> .el-checkbox {
margin-right: 12px;
}
}
.el-checkbox {
padding: 8px 16px;
border-radius: 10px;
background: #eaefff;
margin-bottom: 12px;
height: 40px;
.el-checkbox__input {
.el-checkbox__inner {
border: 1px solid #4dc100;
}
}
.el-checkbox__input.is-checked {
.el-checkbox__inner {
background: #4dc100;
}
}
.el-checkbox__input.is-checked.is-disabled {
.el-checkbox__inner::after {
border-color: #ffffff;
}
}
}
.el-checkbox__label {
color: #7579eb !important;
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
display: flex;
align-items: center;
.checkbox-icon {
margin-left: 4px;
}
}
.el-checkbox + .el-checkbox {
margin-left: 12px;
}
}
}
.prompt-item {
// padding: 0 0 0 18px;
margin-bottom: 12px;
&-label {
// margin-bottom: 16px;
}
}
.file-content {
padding: 0 0 0 18px;
font-size: 14px;
display: flex;
align-items: center;
.document-icon {
width: 16px;
height: 16px;
margin-right: 4px;
}
.file-name {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.timbre-model {
padding: 0 0 0 18px;
margin-bottom: 12px;
display: flex;
align-items: center;
&-label {
width: 120px;
flex-shrink: 0;
}
&-content {
flex: 1;
margin-left: 16px;
}
}
.voice-prompt-box {
border: 1px solid #eaefff;
margin-left: 18px;
padding: 12px;
width: 50%;
}
}
</style>

View File

@@ -0,0 +1,456 @@
<template>
<div :class="`user-config ${t('modelConfigTitle') === '模型配置' ? '' : 'en-user-config'}`">
<div class="user-config-title">{{ t('modelConfigTitle') }}</div>
<div class="config-item">
<div class="config-item-label">
<span>{{ t('audioInterruptionBtn') }}</span>
<el-tooltip class="box-item" effect="dark" :content="t('audioInterruptionTips')" placement="top">
<SvgIcon name="question" class="question-icon" /> </el-tooltip
>:
</div>
<div class="config-item-content">
<el-switch
v-model="configData.canStopByVoice"
inline-prompt
:active-text="t('yes')"
:inactive-text="t('no')"
size="small"
:disabled="isCalling"
/>
</div>
</div>
<div class="config-item" v-if="type === 'video'">
<div class="config-item-label">
<span>{{ t('videoQualityBtn') }}</span>
<el-tooltip class="box-item" effect="dark" :content="t('videoQualityTips')" placement="top">
<SvgIcon name="question" class="question-icon" /> </el-tooltip
>:
</div>
<div class="config-item-content">
<el-switch
v-model="configData.videoQuality"
inline-prompt
:active-text="t('yes')"
:inactive-text="t('no')"
size="small"
:disabled="isCalling"
/>
</div>
</div>
<div class="config-item">
<div class="config-item-label">
<span>{{ t('vadThresholdBtn') }}</span>
<el-tooltip class="box-item" effect="dark" :content="t('vadThresholdTips')" placement="top">
<SvgIcon name="question" class="question-icon" /> </el-tooltip
>:
</div>
<div class="config-item-content vad-slider">
<el-slider
v-model="configData.vadThreshold"
:min="0.5"
:max="1"
:step="0.1"
size="small"
:disabled="isCalling"
/>
</div>
</div>
<div class="prompt-item" v-if="type === 'voice'">
<div class="prompt-item-label">
<span>{{ t('assistantPromptBtn') }}</span>
<el-tooltip class="box-item" effect="dark" :content="t('assistantPromptTips')" placement="top">
<SvgIcon name="question" class="question-icon" /> </el-tooltip
>:
</div>
<div class="prompt-item-content">
<el-input
type="textarea"
:rows="3"
v-model="configData.assistantPrompt"
resize="none"
:disabled="isCalling"
/>
</div>
</div>
<!-- <div class="config-item">
<div class="config-item-label">{{ t('useVoicePromptBtn') }}:</div>
<div class="config-item-content">
<el-switch
v-model="configData.useAudioPrompt"
inline-prompt
:active-text="t('yes')"
:inactive-text="t('no')"
size="small"
:disabled="isCalling"
@change="handleSelectUseAudioPrompt"
/>
</div>
</div> -->
<div class="timbre-model">
<div class="timbre-model-label">
<span>{{ t('toneColorOptions') }}</span>
<el-tooltip class="box-item" effect="dark" :content="t('toneColorOptionsTips')" placement="top">
<SvgIcon name="question" class="question-icon" /> </el-tooltip
>:
</div>
<div class="timbre-model-content">
<el-select
v-model="configData.useAudioPrompt"
style="width: 100%"
@change="handleChangePeople"
placeholder="请选择"
:disabled="isCalling"
>
<el-option :value="0" :label="t('nullOption')">{{ t('nullOption') }}</el-option>
<el-option :value="1" :label="t('defaultOption')">{{ t('defaultOption') }}</el-option>
<el-option :value="2" :label="t('femaleOption')">{{ t('femaleOption') }}</el-option>
<el-option :value="3" :label="t('maleOption')">{{ t('maleOption') }}</el-option>
</el-select>
</div>
</div>
<!-- <div class="prompt-item">
<div class="prompt-item-label">
<span>{{ t('voiceClonePromptInput') }}</span>
<el-tooltip class="box-item" effect="dark" :content="t('voiceClonePromptTips')" placement="top">
<SvgIcon name="question" class="question-icon" /> </el-tooltip
>:
</div>
<div class="prompt-item-content">
<el-input
type="textarea"
:rows="3"
v-model="configData.voiceClonePrompt"
resize="none"
:disabled="true"
/>
</div>
</div> -->
<!-- <div class="timbre-config" v-if="configData.useAudioPrompt">
<div class="timbre-config-label">{{ t('audioChoiceBtn') }}:</div>
<div class="timbre-config-content">
<el-checkbox-group v-model="configData.timbre" @change="handleSelectTimbre" :disabled="isCalling">
<el-checkbox :value="1" :label="t('defaultAudioBtn')"></el-checkbox>
<el-upload
v-model:file-list="fileList"
action=""
:multiple="false"
:on-change="handleChangeFile"
:auto-upload="false"
:show-file-list="false"
:disabled="isCalling"
accept="audio/*"
>
<el-checkbox :value="2">
<span>{{ t('customizationBtn') }}</span>
<SvgIcon name="upload" className="checkbox-icon" />
</el-checkbox>
</el-upload>
</el-checkbox-group>
</div>
</div>
<div class="file-content" v-if="fileName">
<SvgIcon name="document" class="document-icon" />
<span class="file-name">{{ fileName }}</span>
</div> -->
</div>
</template>
<script setup>
const isCalling = defineModel('isCalling');
const type = defineModel('type');
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();
let defaultVoiceClonePrompt =
'你是一个AI助手。你能接受视频音频和文本输入并输出语音和文本。模仿输入音频中的声音特征。';
let defaultAssistantPrompt = '';
const fileList = ref([]);
const fileName = ref('');
const configData = ref({
canStopByVoice: false,
videoQuality: false,
useAudioPrompt: 1,
vadThreshold: 0.8,
voiceClonePrompt: defaultVoiceClonePrompt,
assistantPrompt: defaultAssistantPrompt,
timbre: [1],
audioFormat: 'mp3',
base64Str: ''
});
// let peopleList = [];
// watch(
// () => type.value,
// val => {
// console.log('val: ', val);
// if (val === 'video') {
// defaultVoiceClonePrompt =
// '你是一个AI助手。你能接受视频音频和文本输入并输出语音和文本。模仿输入音频中的声音特征。';
// defaultAssistantPrompt = '作为助手,你将使用这种声音风格说话。';
// } else {
// defaultVoiceClonePrompt = '克隆音频提示中的音色以生成语音。';
// defaultAssistantPrompt = 'Your task is to be a helpful assistant using this voice pattern.';
// }
// configData.value.voiceClonePrompt = defaultVoiceClonePrompt;
// configData.value.assistantPrompt = defaultAssistantPrompt;
// },
// { immediate: true }
// );
watch(
locale,
(newLocale, oldLocale) => {
console.log(`Language switched from ${oldLocale} to ${newLocale}`);
if (newLocale === 'zh' && type.value === 'video') {
defaultAssistantPrompt = '作为助手,你将使用这种声音风格说话。';
} else if (newLocale === 'zh' && type.value === 'voice') {
defaultAssistantPrompt = '作为助手,你将使用这种声音风格说话。';
} else if (newLocale === 'en' && type.value === 'video') {
defaultAssistantPrompt = 'As an assistant, you will speak using this voice style.';
} else {
defaultAssistantPrompt = 'As an assistant, you will speak using this voice style.';
}
configData.value.assistantPrompt = defaultAssistantPrompt;
},
{ immediate: true }
);
onMounted(() => {
handleSetStorage();
});
const handleSelectTimbre = e => {
if (e.length > 1) {
const val = e[e.length - 1];
configData.value.timbre = [val];
// 默认音色
if (val === 1) {
configData.value.audioFormat = 'mp3';
configData.value.base64Str = '';
fileList.value = [];
fileName.value = '';
}
}
};
const handleChangeFile = file => {
if (isAudio(file) && sizeNotExceed(file)) {
fileList.value = [file];
fileName.value = file.name;
configData.value.timbre = [2];
handleUpload();
} else {
ElMessage.error('Please upload audio file and size not exceed 10MB');
}
};
const isAudio = file => {
return file.raw.type.includes('audio');
};
const sizeNotExceed = file => {
return file.size / 1024 / 1024 <= 10;
};
const handleUpload = async () => {
const file = fileList.value[0].raw;
if (file) {
const reader = new FileReader();
reader.onload = e => {
const base64String = e.target.result.split(',')[1];
configData.value.audioFormat = file.name.split('.')[1];
configData.value.base64Str = base64String;
};
reader.readAsDataURL(file);
}
};
const handleSelectUseAudioPrompt = val => {
if (val) {
configData.value.voiceClonePrompt = defaultVoiceClonePrompt;
configData.value.assistantPrompt = defaultAssistantPrompt;
}
};
// 配置发生变化更新到localstorage中
watch(configData.value, () => {
handleSetStorage();
});
const handleSetStorage = () => {
const { timbre, canStopByVoice, ...others } = configData.value;
const defaultConfigData = {
canStopByVoice,
...others
};
localStorage.setItem('configData', JSON.stringify(defaultConfigData));
localStorage.setItem('canStopByVoice', canStopByVoice);
};
const handleChangePeople = val => {
console.log('val: ', val);
// const index = peopleList.findIndex(item => item.id === val);
configData.value.voiceClonePrompt = defaultVoiceClonePrompt;
configData.value.assistantPrompt = defaultAssistantPrompt;
configData.value.timbre = [1];
};
</script>
<style lang="less" scoped>
.user-config {
&-title {
height: 61px;
padding: 18px 18px 0;
color: rgba(23, 23, 23, 0.9);
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.config-item {
display: flex;
align-items: center;
width: 100%;
padding: 0 0 0 18px;
margin-bottom: 20px;
&-label {
width: 120px;
flex-shrink: 0;
display: flex;
align-items: center;
}
&-content {
flex: 1;
margin-left: 16px;
.el-radio-group {
.el-radio {
width: 50px;
}
}
}
&-content.vad-slider {
width: 80%;
padding-left: 7px;
margin-right: 20px;
.el-slider__button {
width: 14px;
height: 14px;
}
}
}
.timbre-config {
padding: 0 0 0 18px;
&-label {
margin-bottom: 20px;
display: flex;
align-items: center;
}
&-content {
display: flex;
align-items: center;
.el-checkbox-group {
display: flex;
flex-wrap: wrap;
flex: 1;
> .el-checkbox {
margin-right: 12px;
}
}
.el-checkbox {
padding: 8px 16px;
border-radius: 10px;
background: #eaefff;
margin-bottom: 12px;
height: 40px;
.el-checkbox__input {
.el-checkbox__inner {
border: 1px solid #4dc100;
}
}
.el-checkbox__input.is-checked {
.el-checkbox__inner {
background: #4dc100;
}
}
.el-checkbox__input.is-checked.is-disabled {
.el-checkbox__inner::after {
border-color: #ffffff;
}
}
}
.el-checkbox__label {
color: #7579eb !important;
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
display: flex;
align-items: center;
.checkbox-icon {
margin-left: 4px;
}
}
.el-checkbox + .el-checkbox {
margin-left: 12px;
}
}
}
.prompt-item {
padding: 0 0 0 18px;
margin-bottom: 20px;
&-label {
// margin-bottom: 16px;
display: flex;
align-items: center;
}
}
.file-content {
padding: 0 0 0 18px;
font-size: 14px;
display: flex;
align-items: center;
.document-icon {
width: 16px;
height: 16px;
margin-right: 4px;
}
.file-name {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.timbre-model {
padding: 0 0 0 18px;
margin-bottom: 20px;
display: flex;
align-items: center;
&-label {
width: 120px;
flex-shrink: 0;
display: flex;
align-items: center;
}
&-content {
flex: 1;
margin-left: 16px;
}
}
}
.en-user-config {
.config-item-label {
width: 160px;
}
.timbre-model-label {
width: 160px;
}
}
.question-icon {
width: 14px;
height: 14px;
cursor: pointer;
margin-left: 6px;
}
</style>
<style lang="less">
.el-switch--small .el-switch__core {
min-width: 50px;
}
.el-popper.is-dark {
max-width: 300px;
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<div class="output-area">
<div
:class="`output-area-item ${item.type === 'USER' ? 'user-item' : 'bot-item'}`"
:key="index"
v-for="(item, index) in outputData"
>
<div v-if="item.type === 'USER'" class="user-input">
<audio v-if="item.audio" :src="item.audio" controls></audio>
</div>
<div v-else class="bot-output">
<div class="output-item">{{ item.text }}</div>
<audio v-if="item.audio" :src="item.audio" controls></audio>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
outputData: {
type: Array,
default: () => []
},
containerClass: {
type: String,
default: ''
}
});
watch(
() => props.outputData,
newVal => {
nextTick(() => {
if (newVal && props.containerClass) {
let dom = document.querySelector(`.${props.containerClass}`);
if (dom) {
dom.scrollTop = dom.scrollHeight;
}
}
});
},
{ deep: true }
);
</script>
<style lang="less" scoped>
.output-area {
display: flex;
flex-direction: column;
&-item {
width: fit-content;
}
&-item + &-item {
margin-top: 16px;
}
&-item.user-item {
align-self: flex-end;
.user-input {
}
}
&-item.bot-item {
align-self: flex-start;
width: 100%;
.bot-output {
width: 100%;
display: flex;
flex-direction: column;
.output-item {
padding: 8px 24px;
border-radius: 10px;
color: #202224;
background: #f3f3f3;
max-width: 90%;
width: fit-content;
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
word-break: break-all;
word-wrap: break-word;
white-space: pre-wrap;
display: inline-block;
}
.output-item + audio {
margin-top: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="select-timbre">
<el-checkbox-group v-model="timbre" @change="handleSelectTimbre" :disabled="disabled">
<el-checkbox :value="1" label="Default Audio"></el-checkbox>
<!-- <el-upload
v-model:file-list="fileList"
action=""
:multiple="false"
:on-change="handleChangeFile"
:auto-upload="false"
:show-file-list="false"
:disabled="disabled"
accept="audio/*"
>
<el-checkbox :value="2">
<span>Customization: Upload Audio</span>
<SvgIcon name="upload" className="checkbox-icon" />
</el-checkbox>
</el-upload> -->
</el-checkbox-group>
</div>
</template>
<script setup>
const timbre = defineModel('timbre');
const audioData = defineModel('audioData');
const disabled = defineModel('disabled');
const fileList = ref([]);
const handleSelectTimbre = e => {
if (e.length > 1) {
const val = e[e.length - 1];
timbre.value = [val];
// 默认音色
if (val === 1) {
audioData.value = {
base64Str: '',
type: 'mp3'
};
}
}
};
const handleChangeFile = file => {
if (isAudio(file) && sizeNotExceed(file)) {
fileList.value = [file];
timbre.value = [2];
handleUpload();
} else {
ElMessage.error('Please upload audio file and size not exceed 1MB');
}
};
const isAudio = file => {
return file.name.endsWith('.mp3') || file.name.endsWith('.wav');
};
const sizeNotExceed = file => {
return file.size / 1024 / 1024 <= 1;
};
const handleUpload = async () => {
const file = fileList.value[0].raw;
if (file) {
const reader = new FileReader();
reader.onload = e => {
const base64String = e.target.result.split(',')[1];
audioData.value = {
base64Str: base64String,
type: file.name.split('.')[1]
};
};
reader.readAsDataURL(file);
}
};
</script>
<style lang="less">
.select-timbre {
display: flex;
align-items: center;
.el-checkbox-group {
display: flex;
> .el-checkbox {
margin-right: 12px;
}
}
.el-checkbox {
padding: 8px 16px;
border-radius: 10px;
background: #eaefff;
margin-right: 0;
height: 40px;
.el-checkbox__input {
.el-checkbox__inner {
border: 1px solid #4dc100;
}
}
.el-checkbox__input.is-checked {
.el-checkbox__inner {
background: #4dc100;
}
}
.el-checkbox__input.is-checked.is-disabled {
.el-checkbox__inner::after {
border-color: #ffffff;
}
}
}
.el-checkbox__label {
color: #7579eb !important;
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
display: flex;
align-items: center;
.checkbox-icon {
margin-left: 4px;
}
}
.el-checkbox + .el-checkbox {
margin-left: 12px;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div :class="`skip-btn ${disabled ? 'disabled-btn' : ''}`">
<div class="pause-icon">
<SvgIcon name="pause" className="pause-svg" />
</div>
<span class="btn-text">{{ t('skipMessageBtn') }}</span>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
defineProps({
disabled: {
type: Boolean,
default: false
}
});
</script>
<style lang="less">
.skip-btn {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 8px 14px 8px 10px;
border-radius: 90px;
background: #5865f2;
cursor: pointer;
user-select: none;
.pause-icon {
display: flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
background: #ffffff;
border-radius: 50%;
margin-right: 8px;
.pause-svg {
width: 18px;
height: 18px;
color: #5865f2;
}
}
.btn-text {
color: #fff;
font-family: PingFang SC;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
}
.disabled-btn {
cursor: not-allowed;
background: #f3f3f3;
.pause-icon {
background: #d1d1d1;
.pause-svg {
color: #ffffff;
}
}
.btn-text {
color: #d1d1d1;
}
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<svg :class="iconClass" v-html="content"></svg>
</template>
<script setup>
const props = defineProps({
name: {
type: String,
required: true
},
className: {
type: String,
default: ''
}
});
const content = ref('');
const iconClass = computed(() => ['svg-icon', props.className]);
onMounted(() => {
import(`@/assets/svg/${props.name}.svg`)
.then(module => {
fetch(module.default)
.then(response => response.text())
.then(svg => {
content.value = svg;
});
})
.catch(error => {
console.error(`Error loading SVG icon: ${props.name}`, error);
});
});
</script>
<style lang="less" scoped>
.svg-icon {
width: 24px;
height: 24px;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="bars" id="bars" :style="boxStyle">
<!-- 柱形条 -->
<div class="bar" v-for="(item, index) in defaultList" :key="index" :style="itemAttr(item)"></div>
</div>
</template>
<script setup>
const props = defineProps({
analyser: {
type: Object
},
dataArray: {
type: [Array, Uint8Array]
},
isCalling: {
type: Boolean,
default: false
},
isPlaying: {
type: Boolean,
default: false
},
// 容器高度
boxStyle: {
type: Object,
default: () => {
return {
height: '80px'
};
}
},
// 柱形条宽度
itemStyle: {
type: Object,
default: () => {
return {
width: '6px',
margin: '0 2px',
borderRadius: '5px'
};
}
},
configList: {
type: Array,
default: () => []
}
});
const animationFrameId = ref();
const defaultList = ref([]);
const bgColor = ref('#4c5cf8');
const itemAttr = computed(() => item => {
return {
height: item + 'px',
...props.itemStyle
};
});
watch(
() => props.dataArray,
newVal => {
if (newVal && props.isCalling) {
console.log('draw');
drawBars();
} else {
console.log('stop');
stopDraw();
}
}
);
watch(
() => props.configList,
newVal => {
if (newVal.length > 0) {
defaultList.value = newVal;
}
},
{ immediate: true }
);
watch(
() => props.isPlaying,
newVal => {
if (newVal) {
// 绿色
bgColor.value = '#4dc100';
} else {
// 蓝色
bgColor.value = '#4c5cf8';
}
}
);
function drawBars() {
const bars = document.querySelectorAll('.bar');
if (bars.length === 0) {
cancelAnimationFrame(animationFrameId.value);
return;
}
const maxHeight = document.querySelector('.bars').clientHeight; // 最大高度为容器的高度
const averageVolume = props.dataArray.reduce((sum, value) => sum + value, 0) / props.dataArray.length;
const normalizedVolume = props.isPlaying ? Math.random() : averageVolume / 128; // 将音量数据归一化为0到1之间
bars.forEach((bar, index) => {
const minHeight = defaultList.value[index];
const randomFactor = Math.random() * 1.5 + 0.5; // 随机因子
const newHeight = Math.min(
maxHeight,
minHeight + (maxHeight - minHeight) * normalizedVolume * randomFactor
); // 根据音量设置高度
bar.style.height = `${newHeight}px`; // 设置新的高度
bar.style.backgroundColor = bgColor.value;
});
animationFrameId.value = requestAnimationFrame(drawBars);
}
const stopDraw = () => {
if (animationFrameId.value) {
cancelAnimationFrame(animationFrameId.value);
}
};
</script>
<style lang="less" scoped>
.bars {
display: flex;
justify-content: center;
align-items: center;
}
.bar {
// width: 6px;
// margin: 0 2px;
background-color: #4c5cf8;
transition:
height 0.1s,
background-color 0.1s;
border-radius: 5px; /* 圆角 */
}
</style>