feat: add MiMo TTS provider 语音TTS提供接入MiMo (#752)

* feat: add MiMo TTS provider with preset voices, voice design and voice clone

* refactor: remove MiMo voice clone feature
This commit is contained in:
ZhangKai | 张凯
2026-05-16 08:55:23 +08:00
committed by GitHub
parent 3f8461d9eb
commit 87a8e95d66
13 changed files with 609 additions and 11 deletions
@@ -371,22 +371,22 @@ const canPlaySpeech = computed(() => {
// 只有 assistant 消息可以播放
if (props.message.role !== 'assistant') return false
if (!copyableContent.value) return false
// OpenAI / Custom / Edge 不依赖浏览器 Web Speech API
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge') return true
// OpenAI / Custom / Edge / MiMo 不依赖浏览器 Web Speech API
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') return true
return speech.isSupported
})
const isPlayingThisMessage = computed(() => {
// OpenAI / Custom / Edge 模式
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge') {
// OpenAI / Custom / Edge / MiMo 模式
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPlaying.value
}
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value
})
const isPausedThisMessage = computed(() => {
// OpenAI / Custom / Edge 模式
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge') {
// OpenAI / Custom / Edge / MiMo 模式
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPaused.value
}
return speech.currentMessageId.value === props.message.id && speech.isPaused.value
@@ -441,6 +441,24 @@ function handleSpeechToggle() {
return
}
// MiMo TTS 模式
if (voiceSettings.provider.value === 'mimo') {
const apiKey = voiceSettings.mimoApiKey.value
if (!apiKey) {
console.warn('[MessageItem] MiMo TTS API Key 为空')
return
}
speech.mimoToggle(props.message.id, content, {
baseUrl: voiceSettings.mimoBaseUrl.value,
apiKey,
model: voiceSettings.mimoModel.value,
voice: voiceSettings.mimoVoice.value,
voiceDesignDesc: voiceSettings.mimoVoiceDesignDesc.value || undefined,
stylePrompt: voiceSettings.mimoStylePrompt.value || undefined,
})
return
}
// Web Speech API 模式
if (voiceSettings.provider.value === 'webspeech') {
const text = speech.extractReadableText(content)
@@ -486,6 +504,18 @@ onMounted(() => {
rate: speedToEdgeRate(voiceSettings.edgeRate.value),
pitch: hzToEdgePitch(voiceSettings.edgePitchHz.value),
})
} else if (voiceSettings.provider.value === 'mimo') {
const apiKey = voiceSettings.mimoApiKey.value
if (apiKey) {
speech.mimoPlay(props.message.id, content, {
baseUrl: voiceSettings.mimoBaseUrl.value,
apiKey,
model: voiceSettings.mimoModel.value,
voice: voiceSettings.mimoVoice.value,
voiceDesignDesc: voiceSettings.mimoVoiceDesignDesc.value || undefined,
stylePrompt: voiceSettings.mimoStylePrompt.value || undefined,
})
}
} else if (voiceSettings.provider.value === 'webspeech') {
const text = speech.extractReadableText(content)
if (text) {
@@ -19,6 +19,7 @@ const providerOptions = [
{ label: t('settings.voice.providerOpenai'), value: 'openai' },
{ label: t('settings.voice.providerCustom'), value: 'custom' },
{ label: t('settings.voice.providerEdge'), value: 'edge' },
{ label: t('settings.voice.providerMimo'), value: 'mimo' },
]
const openaiModelOptions = [
@@ -76,6 +77,28 @@ onMounted(() => {
}
})
// ── MiMo TTS options ──
const mimoBaseUrlOptions = [
{ label: 'https://api.xiaomimimo.com/v1', value: 'https://api.xiaomimimo.com/v1' },
{ label: 'https://token-plan-cn.xiaomimimo.com/v1', value: 'https://token-plan-cn.xiaomimimo.com/v1' },
]
const mimoModelOptions = [
{ label: t('settings.voice.mimoModelPreset'), value: 'mimo-v2.5-tts' },
{ label: t('settings.voice.mimoModelVoiceDesign'), value: 'mimo-v2.5-tts-voicedesign' },
]
const mimoVoiceOptions = [
{ label: '冰糖 (中文·女)', value: '冰糖' },
{ label: '茉莉 (中文·女)', value: '茉莉' },
{ label: '苏打 (中文·男)', value: '苏打' },
{ label: '白桦 (中文·男)', value: '白桦' },
{ label: 'Mia (English·Female)', value: 'Mia' },
{ label: 'Chloe (English·Female)', value: 'Chloe' },
{ label: 'Milo (English·Male)', value: 'Milo' },
{ label: 'Dean (English·Male)', value: 'Dean' },
]
async function handleTest() {
const text = testText.value.trim()
if (!text) return
@@ -113,6 +136,19 @@ async function handleTest() {
rate: speedToEdgeRate(vs.edgeRate.value),
pitch: hzToEdgePitch(vs.edgePitchHz.value),
})
} else if (vs.provider.value === 'mimo') {
if (!vs.mimoApiKey.value) {
console.warn('[VoiceSettings] MiMo API Key empty')
return
}
await speech.mimoPlay('__test__', text, {
baseUrl: vs.mimoBaseUrl.value,
apiKey: vs.mimoApiKey.value,
model: vs.mimoModel.value,
voice: vs.mimoVoice.value,
voiceDesignDesc: vs.mimoVoiceDesignDesc.value || undefined,
stylePrompt: vs.mimoStylePrompt.value || undefined,
})
}
} catch (err) {
console.error('[VoiceSettings] Test failed:', err)
@@ -312,6 +348,104 @@ async function handleTest() {
</template>
<!-- MiMo TTS -->
<template v-if="vs.provider.value === 'mimo'">
<div class="provider-hint">
{{ t('settings.voice.mimoHint') }}
</div>
<SettingRow
:label="t('settings.voice.mimoApiKey')"
:hint="t('settings.voice.mimoApiKeyHint')"
>
<NInput
:value="vs.mimoApiKey.value"
type="password"
size="small"
show-password-on="click"
style="width: 360px"
:placeholder="t('settings.voice.mimoApiKeyPlaceholder')"
@update:value="vs.setMimoApiKey"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.mimoBaseUrl')"
:hint="t('settings.voice.mimoBaseUrlHint')"
>
<NSelect
:value="vs.mimoBaseUrl.value"
:options="mimoBaseUrlOptions"
size="small"
filterable
tag
style="width: 360px"
@update:value="vs.setMimoBaseUrl"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.mimoModel')"
:hint="t('settings.voice.mimoModelHint')"
>
<NSelect
:value="vs.mimoModel.value"
:options="mimoModelOptions"
size="small"
style="width: 320px"
@update:value="vs.setMimoModel"
/>
</SettingRow>
<!-- Preset voice mode -->
<SettingRow
v-if="vs.mimoModel.value === 'mimo-v2.5-tts'"
:label="t('settings.voice.mimoVoice')"
:hint="t('settings.voice.mimoVoiceHint')"
>
<NSelect
:value="vs.mimoVoice.value"
:options="mimoVoiceOptions"
size="small"
style="width: 200px"
@update:value="vs.setMimoVoice"
/>
</SettingRow>
<!-- Voice design mode -->
<SettingRow
v-if="vs.mimoModel.value === 'mimo-v2.5-tts-voicedesign'"
:label="t('settings.voice.mimoVoiceDesignPrompt')"
:hint="t('settings.voice.mimoVoiceDesignPromptHint')"
>
<NInput
:value="vs.mimoVoiceDesignDesc.value"
type="textarea"
size="small"
style="width: 360px"
:rows="3"
:placeholder="t('settings.voice.mimoVoiceDesignPromptPlaceholder')"
@update:value="vs.setMimoVoiceDesignDesc"
/>
</SettingRow>
<!-- Style prompt (available for all models) -->
<SettingRow
:label="t('settings.voice.mimoStylePrompt')"
:hint="t('settings.voice.mimoStylePromptHint')"
>
<NInput
:value="vs.mimoStylePrompt.value"
type="textarea"
size="small"
style="width: 360px"
:rows="2"
:placeholder="t('settings.voice.mimoStylePromptPlaceholder')"
@update:value="vs.setMimoStylePrompt"
/>
</SettingRow>
</template>
<!-- Test / Audition -->
<div class="test-section">
<h4 class="test-title">{{ t('settings.voice.testTitle') }}</h4>