VoiceDialogue / frontend /src /views /Welcome /Components /SettingsModal.vue
hzeng412
Make Qwen3-ASR the default on main; bump to 1.2.0
3c70498
Raw
History Blame Contribute Delete
23.4 kB
<script setup lang="ts">
import { ref, reactive, computed, watch, onUnmounted } from "vue";
import { Modal } from "ant-design-vue";
import { SoundTwoTone, SoundOutlined, TranslationOutlined, AudioOutlined } from "@ant-design/icons-vue";
import { useI18n } from "vue-i18n";
import axios from "axios";
import { useSettingsStore } from "@/stores/config.ts";
import { setUiLocale, UiLocale } from "@/i18n";
const props = defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(["update:open"]);
const { t } = useI18n();
const base_url = axios.defaults.baseURL;
const settingsStore = useSettingsStore();
const activeTab = ref<string>("main");
const loading = ref<boolean>(false);
const appVersion = "1.2.0";
// ---- 各项设置的本地状态(打开时从 store / 后端同步)----
const uiLanguage = ref<UiLocale>((settingsStore.$state.uiLanguage as UiLocale) ?? "en");
const recognitionLanguage = ref<string>(settingsStore.$state.language || "zh");
const echoCancel = ref<boolean>(settingsStore.$state.echoCancel ?? true);
const inputDeviceIndex = ref<number | null>(settingsStore.$state.inputDeviceIndex ?? null);
const outputDeviceIndex = ref<number | null>(settingsStore.$state.outputDeviceIndex ?? null);
const role = ref<string>(settingsStore.$state.role || "");
const languages = reactive<string[]>([]);
const inputDevices = reactive<any[]>([]);
const outputDevices = reactive<any[]>([]);
const roles = reactive<any[]>([]);
// ---- Prompt ----
const promptLang = ref<string>("zh");
const default_prompt_en = ref<string>("");
const default_prompt_zh = ref<string>("");
const current_prompt_en = ref<string>("");
const current_prompt_zh = ref<string>("");
const filteredRoles = computed(() => {
const is_chinese = recognitionLanguage.value === "zh";
return roles.filter((r) => r["is_chinese_voice"] === is_chinese);
});
// 切换识别语言后,自动选中第一个匹配音色
watch(
() => recognitionLanguage.value,
() => {
if (filteredRoles.value.length > 0) {
const exists = filteredRoles.value.find((r) => r["id"] === role.value);
role.value = exists ? role.value : filteredRoles.value[0]["id"];
} else {
role.value = "";
}
}
);
// 界面语言即时生效(让用户立刻看到切换效果)
watch(uiLanguage, (v) => setUiLocale(v));
// ---- 数据加载 ----
const fetchASRLanguages = async () => {
try {
const res = await fetch(`${base_url}/asr/languages`);
const data = await res.json();
if (data?.languages) {
languages.splice(0, languages.length, ...data.languages);
// 优先沿用本地已保存/默认的识别语言(默认中文),不被后端当前值覆盖
const saved = settingsStore.$state.language;
recognitionLanguage.value = saved && data.languages.includes(saved)
? saved
: (data.languages.includes('zh') ? 'zh' : data.languages[0]);
}
} catch (e) {
console.error("Error fetching ASR languages:", e);
}
};
const fetchTTSRoles = async () => {
try {
const res = await fetch(`${base_url}/tts/models`);
const data = await res.json();
if (data?.models) {
roles.splice(0, roles.length, ...data.models);
if (data.current_model_id) role.value = data.current_model_id;
}
} catch (e) {
console.error("Error fetching TTS roles:", e);
}
};
const fetchInputDevices = async () => {
try {
const res = await fetch(`${base_url}/system/audio-devices`);
const data = await res.json();
if (data?.devices) {
inputDevices.splice(0, inputDevices.length, ...data.devices);
const saved = settingsStore.$state.inputDeviceIndex;
const exists = saved != null && data.devices.some((d: any) => d.index === saved);
inputDeviceIndex.value = exists ? saved : (data.current_device_index ?? null);
}
if (data?.output_devices) {
outputDevices.splice(0, outputDevices.length, ...data.output_devices);
const saved = settingsStore.$state.outputDeviceIndex;
const exists = saved != null && data.output_devices.some((d: any) => d.index === saved);
outputDeviceIndex.value = exists ? saved : (data.current_output_device_index ?? null);
}
} catch (e) {
console.error("Error fetching input devices:", e);
}
};
// 当前实际生效的 ASR 引擎(由后端返回,区分 Qwen / FunASR+Whisper 等)
const asrEngineName = ref<string>("");
const asrEngineKeys = ref<string[]>([]);
const ASR_ENGINE_LINKS: Record<string, { name: string; url: string }> = {
qwen: { name: "Qwen3-ASR", url: "https://huggingface.co/Qwen/Qwen3-ASR-1.7B" },
whisper: { name: "whisper.cpp", url: "https://github.com/ggerganov/whisper.cpp" },
funasr: { name: "FunASR", url: "https://github.com/modelscope/FunASR" },
};
const asrEngineLinks = computed(() => {
const keys = asrEngineKeys.value.length ? asrEngineKeys.value : ["whisper", "funasr"];
return keys.map((k) => ASR_ENGINE_LINKS[k]).filter(Boolean);
});
const fetchAsrEngine = async () => {
try {
const res = await fetch(`${base_url}/system/asr-engine`);
const data = await res.json();
if (data?.display_name) asrEngineName.value = data.display_name;
if (data?.mappings) asrEngineKeys.value = [...new Set(Object.values(data.mappings) as string[])].sort();
} catch (e) {
console.error("Error fetching ASR engine:", e);
}
};
const fetchPrompts = async () => {
try {
const [cur, def] = await Promise.all([
fetch(`${base_url}/settings/settings/prompts`).then((r) => r.json()),
fetch(`${base_url}/settings/settings/prompts/default`).then((r) => r.json()),
]);
if (cur) {
current_prompt_en.value = cur.english_prompt;
current_prompt_zh.value = cur.chinese_prompt;
}
if (def) {
default_prompt_en.value = def.english_prompt;
default_prompt_zh.value = def.chinese_prompt;
}
} catch (e) {
console.error("Error fetching prompts:", e);
}
};
const resetPrompt = (lang: string) => {
if (lang === "en") current_prompt_en.value = default_prompt_en.value;
else current_prompt_zh.value = default_prompt_zh.value;
};
// ---- 提交 / 取消 ----
const applySettings = async () => {
loading.value = true;
try {
// 1. 持久化到本地 store
settingsStore.$state.uiLanguage = uiLanguage.value;
settingsStore.$state.language = recognitionLanguage.value;
settingsStore.$state.role = role.value || "";
settingsStore.$state.echoCancel = echoCancel.value;
settingsStore.$state.inputDeviceIndex = inputDeviceIndex.value;
settingsStore.$state.outputDeviceIndex = outputDeviceIndex.value;
// 输出设备保存即生效(会话中修改下一句生效)
await fetch(`${base_url}/system/audio-output-device`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ output_device_index: outputDeviceIndex.value }),
});
// 2. 下发 TTS 音色 + ASR 语言
if (role.value) {
const r1 = await fetch(`${base_url}/tts/models/load`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_id: role.value }),
});
if (!r1.ok) throw new Error(`TTS load failed: ${r1.status}`);
}
const r2 = await fetch(`${base_url}/asr/instance/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ language: recognitionLanguage.value }),
});
if (!r2.ok) throw new Error(`ASR set failed: ${r2.status}`);
// 3. 保存 Prompt
await fetch(`${base_url}/settings/settings/prompts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chinese_prompt: current_prompt_zh.value,
english_prompt: current_prompt_en.value,
}),
});
emit("update:open", false);
} catch (err) {
console.error("Error applying settings:", err);
Modal.error({ title: t("common.error"), content: t("settings.applyFailed") });
} finally {
loading.value = false;
}
};
const handleCancel = () => {
// 还原本地状态与界面语言
uiLanguage.value = (settingsStore.$state.uiLanguage as UiLocale) ?? "en";
setUiLocale(uiLanguage.value);
recognitionLanguage.value = settingsStore.$state.language || "zh";
echoCancel.value = settingsStore.$state.echoCancel ?? true;
inputDeviceIndex.value = settingsStore.$state.inputDeviceIndex ?? null;
outputDeviceIndex.value = settingsStore.$state.outputDeviceIndex ?? null;
role.value = settingsStore.$state.role || "";
emit("update:open", false);
};
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
activeTab.value = "main";
uiLanguage.value = (settingsStore.$state.uiLanguage as UiLocale) ?? "en";
fetchASRLanguages();
fetchTTSRoles();
fetchInputDevices();
fetchPrompts();
fetchAsrEngine();
}
}
);
// ---- 音色试听 ----
const currentPlayingId = ref<string | null>(null);
const currentAudio = ref<HTMLAudioElement | null>(null);
const isPlaying = (id: string) => currentPlayingId.value === id;
const playRefAudio = async (id: string, e: Event) => {
e.stopPropagation();
e.preventDefault();
try {
if (currentPlayingId.value === id && currentAudio.value) {
currentAudio.value.pause();
currentAudio.value = null;
currentPlayingId.value = null;
return;
}
if (currentAudio.value) {
currentAudio.value.pause();
currentAudio.value = null;
}
const audio = new Audio(`${base_url}/tts/models/${id}/reference-audio`);
audio.addEventListener("ended", () => {
currentPlayingId.value = null;
currentAudio.value = null;
});
await audio.play();
currentPlayingId.value = id;
currentAudio.value = audio;
} catch (err) {
currentPlayingId.value = null;
currentAudio.value = null;
}
};
onUnmounted(() => {
if (currentAudio.value) currentAudio.value.pause();
});
</script>
<template>
<a-modal
:open="props.open"
:title="t('settings.title')"
:mask-closable="false"
:closable="true"
:width="600"
centered
transition-name="ant-fade"
@cancel="handleCancel"
@update:open="(v: boolean) => emit('update:open', v)"
>
<template #footer>
<a-button key="back" @click="handleCancel">{{ t('common.cancel') }}</a-button>
<a-button key="confirm" type="primary" :loading="loading" @click="applySettings">
{{ t('common.confirm') }}
</a-button>
</template>
<a-tabs v-model:activeKey="activeTab" class="settings-tabs">
<!-- 常用:输入源 + 回音消除 + 音色(大家最关心的) -->
<a-tab-pane key="main" :tab="t('settings.tabs.main')">
<div class="tab-body">
<div class="setting-row">
<label>{{ t('settings.audio.microphone') }}</label>
<a-select v-model:value="inputDeviceIndex" style="width: 100%;">
<a-select-option :value="null">{{ t('settings.audio.systemDefault') }}</a-select-option>
<a-select-option v-for="dev in inputDevices" :value="dev.index" :key="dev.index">
{{ dev.name }}<template v-if="dev.max_input_channels > 1"> ({{ dev.max_input_channels }}{{ t('settings.audio.channelsSuffix') }})</template><template v-if="dev.is_default"> · {{ t('settings.audio.defaultSuffix') }}</template>
</a-select-option>
</a-select>
</div>
<div class="setting-row">
<label>{{ t('settings.audio.speaker') }}</label>
<a-select v-model:value="outputDeviceIndex" style="width: 100%;">
<a-select-option :value="null">{{ t('settings.audio.systemDefault') }}</a-select-option>
<a-select-option v-for="dev in outputDevices" :value="dev.index" :key="dev.index">
{{ dev.name }}<template v-if="dev.is_default"> · {{ t('settings.audio.defaultSuffix') }}</template>
</a-select-option>
</a-select>
</div>
<div class="setting-row">
<div class="row-inline">
<label>{{ t('settings.audio.echoCancellation') }}</label>
<a-switch v-model:checked="echoCancel" />
</div>
</div>
<div class="setting-row">
<label>{{ t('settings.voice.role') }}</label>
<a-radio-group v-model:value="role" class="voice-group">
<a-radio v-for="r in filteredRoles" :value="r['id']" :key="r['id']" class="voice-radio">
<span class="voice-name">{{ r['character_name'] }}</span>
<a-button
type="text"
class="audio-play-btn"
:class="{ playing: isPlaying(r['id']) }"
@click="playRefAudio(r['id'], $event)"
>
<SoundTwoTone v-if="isPlaying(r['id'])" style="font-size: 16px; color: #52c41a;" />
<SoundOutlined v-else style="font-size: 16px; color: #1890ff;" />
</a-button>
</a-radio>
</a-radio-group>
</div>
</div>
</a-tab-pane>
<!-- 语言:界面语言 + 识别语言 -->
<a-tab-pane key="language" :tab="t('settings.tabs.language')">
<div class="tab-body">
<div class="setting-row">
<label><TranslationOutlined class="label-icon" />{{ t('settings.general.interfaceLanguage') }}</label>
<a-select v-model:value="uiLanguage" style="width: 100%;">
<a-select-option value="zh">{{ t('lang.zh') }}</a-select-option>
<a-select-option value="en">{{ t('lang.en') }}</a-select-option>
</a-select>
<p class="hint">{{ t('settings.general.interfaceLanguageHint') }}</p>
</div>
<div class="setting-row">
<label><AudioOutlined class="label-icon" />{{ t('settings.recognition.language') }}</label>
<a-select v-model:value="recognitionLanguage" style="width: 100%;">
<a-select-option v-for="lan in languages" :value="lan" :key="lan">
{{ t('lang.' + lan) }}
</a-select-option>
</a-select>
<p class="hint">{{ t('settings.recognition.languageHint') }}</p>
</div>
</div>
</a-tab-pane>
<!-- 高级:系统提示词 -->
<a-tab-pane key="advanced" :tab="t('settings.tabs.advanced')">
<div class="tab-body">
<div class="setting-row">
<label>{{ t('settings.prompt.title') }}</label>
<a-radio-group button-style="solid" size="small" v-model:value="promptLang" style="margin-bottom: 12px;">
<a-radio-button value="zh">{{ t('lang.zh') }}</a-radio-button>
<a-radio-button value="en">{{ t('lang.en') }}</a-radio-button>
</a-radio-group>
<div v-show="promptLang === 'zh'">
<a-textarea v-model:value="current_prompt_zh" :placeholder="default_prompt_zh"
:auto-size="{ minRows: 6, maxRows: 10 }" show-count :maxlength="2000" allow-clear />
<a-button size="small" @click="resetPrompt('zh')" style="margin-top: 12px;">{{ t('common.reset') }}</a-button>
</div>
<div v-show="promptLang === 'en'">
<a-textarea v-model:value="current_prompt_en" :placeholder="default_prompt_en"
:auto-size="{ minRows: 6, maxRows: 10 }" show-count :maxlength="2000" allow-clear />
<a-button size="small" @click="resetPrompt('en')" style="margin-top: 12px;">{{ t('common.reset') }}</a-button>
</div>
</div>
</div>
</a-tab-pane>
<!-- 关于 -->
<a-tab-pane key="about" :tab="t('settings.tabs.about')">
<div class="tab-body about">
<div class="about-head">
<div class="about-name">Voice Dialogue</div>
<div class="about-ver">{{ t('settings.about.version') }} {{ appVersion }}</div>
<div class="about-tagline">{{ t('settings.about.tagline') }}</div>
</div>
<div class="about-section">
<div class="about-section-title">{{ t('settings.about.modelsTitle') }}</div>
<div class="about-item">
<div class="about-item-label">{{ t('settings.about.llm') }}</div>
<div class="about-item-desc">
{{ t('settings.about.llmDesc') }}
<a href="https://huggingface.co/Qwen/Qwen3-8B" target="_blank" rel="noopener">Qwen3 ↗</a>
</div>
</div>
<div class="about-item">
<div class="about-item-label">{{ t('settings.about.asr') }}</div>
<div class="about-item-desc">
{{ asrEngineName || t('settings.about.asrDesc') }}
<a v-for="link in asrEngineLinks" :key="link.url" :href="link.url" target="_blank"
rel="noopener">{{ link.name }} ↗</a>
</div>
</div>
<div class="about-item">
<div class="about-item-label">{{ t('settings.about.tts') }}</div>
<div class="about-item-desc">
{{ t('settings.about.ttsDesc') }}
<a href="https://github.com/RVC-Boss/GPT-SoVITS" target="_blank" rel="noopener">GPT-SoVITS ↗</a>
<a href="https://huggingface.co/hexgrad/Kokoro-82M" target="_blank" rel="noopener">Kokoro ↗</a>
</div>
</div>
</div>
<div class="about-section">
<div class="about-section-title">{{ t('settings.about.linksTitle') }}</div>
<div class="about-item">
<div class="about-item-label">{{ t('settings.about.repoApp') }}</div>
<a class="about-link" href="https://huggingface.co/MoYoYoTech/VoiceDialogue" target="_blank" rel="noopener">huggingface.co/MoYoYoTech/VoiceDialogue</a>
</div>
<div class="about-item">
<div class="about-item-label">{{ t('settings.about.repoVoices') }}</div>
<a class="about-link" href="https://huggingface.co/MoYoYoTech/tone-models" target="_blank" rel="noopener">huggingface.co/MoYoYoTech/tone-models</a>
</div>
</div>
<div class="about-copyright">{{ t('settings.about.copyright') }}</div>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
<style lang="scss" scoped>
// 固定内容区高度,切换 Tab 时横条不再跳动
.tab-body {
height: 360px;
overflow-y: auto;
padding: 4px 8px 4px 2px;
}
.setting-row {
margin-bottom: 20px;
// 仅作用于字段标题(直接子 label),避免影响嵌套的 radio-button 等 <label>
> label {
display: block;
font-size: 15px;
font-weight: 500;
margin-bottom: 8px;
.label-icon {
margin-right: 6px;
color: #1890ff;
}
}
.hint {
font-size: 12px;
color: #999;
margin: 8px 0 0;
}
.row-inline {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.voice-group {
display: flex;
flex-direction: column;
margin-top: 8px;
}
/* 关于页 */
.about {
.about-head {
text-align: center;
margin-bottom: 24px;
.about-name {
font-size: 20px;
font-weight: 600;
}
.about-ver {
font-size: 13px;
color: #888;
margin-top: 2px;
}
.about-tagline {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
.about-section {
margin-bottom: 20px;
.about-section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 10px;
}
}
.about-item {
margin-bottom: 12px;
.about-item-label {
font-size: 14px;
font-weight: 500;
}
.about-item-desc {
font-size: 12px;
color: #777;
margin-top: 2px;
line-height: 1.6;
a { margin-left: 6px; }
}
}
a {
color: #1677ff;
text-decoration: none;
&:hover { text-decoration: underline; }
}
.about-link {
font-size: 13px;
word-break: break-all;
}
.about-copyright {
margin-top: 16px;
font-size: 11px;
color: #aaa;
text-align: center;
}
}
.voice-radio {
display: flex;
align-items: center;
height: 40px;
line-height: 40px;
.voice-name {
margin-right: 8px;
}
}
.audio-play-btn {
padding: 0 6px;
border-radius: 4px;
&.playing {
background-color: #f6ffed;
}
}
</style>