feat(providers): add Xiaomi Mimo (#1760)

This commit is contained in:
kmiit
2026-05-06 03:39:10 +08:00
committed by GitHub
parent e50e140a70
commit 5205f369bc
21 changed files with 818 additions and 0 deletions
+1
View File
@@ -359,6 +359,7 @@ npx bumpp --no-commit --no-tag
- [x] [Tencent Cloud](https://cloud.tencent.com/document/product/1729)
- [ ] [Sparks](https://www.xfyun.cn/doc/spark/Web.html) (PR welcome)
- [ ] [Volcano Engine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI) (PR welcome)
- [x] [Xiaomi Mimo](https://platform.xiaomimimo.com)
## Sub-projects Born from This Project
+1
View File
@@ -319,6 +319,7 @@ npx bumpp --no-commit --no-tag
- [x] [Tencent Cloud](https://cloud.tencent.com/document/product/1729)
- [ ] [Sparks](https://www.xfyun.cn/doc/spark/Web.html) (PR bienvenue)
- [ ] [Volcano Engine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI) (PR bienvenue)
- [x] [Xiaomi Mimo](https://platform.xiaomimimo.com)
## Sous-projets issus de ce projet
+1
View File
@@ -322,6 +322,7 @@ npx bumpp --no-commit --no-tag
- [x] [Tencent Cloud](https://cloud.tencent.com/document/product/1729)
- [ ] [Sparks](https://www.xfyun.cn/doc/spark/Web.html)PR歓迎)
- [ ] [Volcano Engine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI)PR歓迎)
- [x] [Xiaomi Mimo](https://platform.xiaomimimo.com)
## このプロジェクトから生まれたサブプロジェクト
+1
View File
@@ -355,6 +355,7 @@ npx bumpp --no-commit --no-tag
- [x] [Tencent Cloud](https://cloud.tencent.com/document/product/1729)
- [ ] [Sparks](https://www.xfyun.cn/doc/spark/Web.html) (PR 환영)
- [ ] [Volcano Engine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI) (PR 환영)
- [x] [Xiaomi Mimo](https://platform.xiaomimimo.com)
## 이 프로젝트에서 탄생한 하위 프로젝트
+1
View File
@@ -319,6 +319,7 @@ npx bumpp --no-commit --no-tag
- [x] [Tencent Cloud](https://cloud.tencent.com/document/product/1729)
- [ ] [Sparks](https://www.xfyun.cn/doc/spark/Web.html) (PR welcome)
- [ ] [Volcano Engine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI) (PR welcome)
- [x] [Xiaomi Mimo](https://platform.xiaomimimo.com)
## Подпроекты, родившиеся из этого проекта
+1
View File
@@ -316,6 +316,7 @@ npx bumpp --no-commit --no-tag
- [x] [Tencent Cloud](https://cloud.tencent.com/document/product/1729)
- [ ] [Sparks](https://www.xfyun.cn/doc/spark/Web.html) (PR welcome)
- [ ] [Volcano Engine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI) (PR welcome)
- [x] [Xiaomi Mimo](https://platform.xiaomimimo.com)
## Các dự án phụ khác
+1
View File
@@ -317,6 +317,7 @@ npx bumpp --no-commit --no-tag
- [x] [腾讯混元](https://cloud.tencent.com/document/product/1729)
- [ ] [讯飞星火](https://www.xfyun.cn/doc/spark/Web.html)
- [ ] [火山引擎(豆包)](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI)
- [x] [小米 Mimo](https://platform.xiaomimimo.com)
## 从这个项目诞生的子项目
@@ -1160,6 +1160,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: Xiaomi MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: Xiaomi MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: Xiaomi MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: Xiaomi MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: Xiaomi MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: Minimax.io
title: MiniMax речь
mimo:
description: api.xiaomimimo.com
title: Xiaomi MiMo
mistral:
description: Mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: Xiaomi MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: 小米 MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -1110,6 +1110,9 @@ pages:
minimax-speech:
description: minimax.io
title: MiniMax Speech
mimo:
description: api.xiaomimimo.com
title: 小米 MiMo
mistral:
description: mistral.ai
title: Mistral
@@ -0,0 +1,250 @@
<script setup lang="ts">
import type { SpeechProvider } from '@xsai-ext/providers/utils'
import {
Alert,
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldCombobox, FieldTextArea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
interface MimoSpeechProviderConfig {
apiKey?: string
baseUrl?: string
format?: string
model?: string
stylePrompt?: string
voice?: string
voiceSample?: string
}
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const { t } = useI18n()
const defaultVoiceSettings = {
speed: 1.0,
}
const providerId = 'mimo-audio-speech'
const defaultModel = 'mimo-v2.5-tts'
const defaultVoice = 'mimo_default'
const config = computed(() => providers.value[providerId] as MimoSpeechProviderConfig | undefined)
function ensureProviderConfig(): MimoSpeechProviderConfig {
if (!providers.value[providerId]) {
providers.value[providerId] = {}
}
return providers.value[providerId] as MimoSpeechProviderConfig
}
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
const modelOptions = computed(() => {
const fallbackOptions = [
{ id: 'mimo-v2.5-tts', name: 'MiMo v2.5 TTS' },
{ id: 'mimo-v2.5-tts-voicedesign', name: 'MiMo v2.5 TTS Voice Design' },
{ id: 'mimo-v2.5-tts-voiceclone', name: 'MiMo v2.5 TTS Voice Clone' },
]
return (providerModels.value.length > 0 ? providerModels.value : fallbackOptions).map(model => ({
value: model.id,
label: model.name,
}))
})
const availableVoices = computed(() => speechStore.availableVoices[providerId] || [])
const isVoiceDesignModel = computed(() => model.value === 'mimo-v2.5-tts-voicedesign')
const isVoiceCloneModel = computed(() => model.value === 'mimo-v2.5-tts-voiceclone')
const stylePromptLabel = computed(() => {
if (isVoiceCloneModel.value)
return 'Style prompt (optional)'
if (isVoiceDesignModel.value)
return 'Voice design prompt'
return 'Style prompt'
})
const stylePromptDescription = computed(() => {
if (isVoiceCloneModel.value) {
return 'Optional natural-language control sent as the user message. Leave it empty for pure voice cloning.'
}
if (isVoiceDesignModel.value) {
return 'Natural-language control sent as the user message. MiMo voice design requires this prompt and does not use a preset voice.'
}
return 'Natural-language control sent as the user message. You can leave it empty for a neutral delivery.'
})
const model = computed({
get: () => config.value?.model || defaultModel,
set: (value) => {
ensureProviderConfig().model = value
},
})
const stylePrompt = computed({
get: () => config.value?.stylePrompt || '',
set: (value) => {
ensureProviderConfig().stylePrompt = value
},
})
const voiceSample = computed({
get: () => config.value?.voiceSample || '',
set: (value) => {
ensureProviderConfig().voiceSample = value
},
})
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
onMounted(async () => {
ensureProviderConfig()
if (!config.value?.model) {
model.value = defaultModel
}
await providersStore.loadModelsForConfiguredProviders()
await providersStore.fetchModelsForProvider(providerId)
await speechStore.loadVoicesForProvider(providerId)
})
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean, modelId?: string) {
const provider = await providersStore.getProviderInstance<SpeechProvider<string>>(providerId)
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
const providerConfig = providersStore.getProviderConfig(providerId)
const modelToUse = modelId || model.value || defaultModel
const requestConfig = {
...providerConfig,
...defaultVoiceSettings,
stylePrompt: stylePrompt.value,
voiceSample: voiceSample.value,
}
if (modelToUse === 'mimo-v2.5-tts-voiceclone' && !voiceSample.value.trim()) {
throw new Error('Voice clone model requires a base64 audio sample in data URI format.')
}
const voiceToUse = modelToUse === 'mimo-v2.5-tts-voiceclone'
? voiceSample.value.trim()
: voiceId || (config.value?.voice || defaultVoice)
return await speechStore.speech(
provider,
modelToUse,
input,
voiceToUse,
requestConfig,
)
}
const {
isValidating,
isValid,
validationMessage,
forceValid,
} = useProviderValidation(providerId)
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<template #voice-settings>
<FieldCombobox
v-model="model"
label="Model"
description="Select the MiMo TTS model to use for speech generation"
:options="modelOptions"
placeholder="Select a MiMo model..."
/>
<FieldTextArea
v-model="stylePrompt"
:label="stylePromptLabel"
:description="stylePromptDescription"
placeholder="Describe the tone, pacing, emotion, and delivery style..."
:required="!isVoiceCloneModel"
/>
<FieldTextArea
v-if="isVoiceCloneModel"
v-model="voiceSample"
label="Voice sample (data URI)"
description="Paste the base64 voice sample as data:{MIME_TYPE};base64,$BASE64_AUDIO. MiMo supports mp3 and wav samples up to 10 MB."
placeholder="data:audio/wav;base64,UklGRpyG..."
:required="isVoiceCloneModel"
/>
</template>
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
:voices-loading="speechStore.isLoadingSpeechProviderVoices"
default-text="Hello! This is a test of the Xiaomi MiMo Speech."
/>
</template>
<template #advanced-settings>
<Alert type="info">
<template #title>
MiMo model behavior
</template>
<template #content>
<div class="whitespace-pre-wrap break-words text-sm space-y-1">
<div>`mimo-v2.5-tts` uses the preset voice list below.</div>
<div>`mimo-v2.5-tts-voicedesign` uses the style prompt to design a new voice and does not accept `audio.voice`.</div>
<div>`mimo-v2.5-tts-voiceclone` uses the pasted voice sample and ignores the preset voice selector.</div>
</div>
</template>
</Alert>
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
TranscriptionPlayground,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldCombobox } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
const providerId = 'mimo-audio-transcription'
const hearingStore = useHearingStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
const model = computed({
get: () => providers.value[providerId]?.model || 'mimo-v2-omni',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].model = value
},
})
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
const isLoadingModels = computed(() => providersStore.isLoadingModels[providerId] || false)
onMounted(async () => {
await providersStore.loadModelsForConfiguredProviders()
await providersStore.fetchModelsForProvider(providerId)
})
async function handleGenerateTranscription(file: File) {
const provider = await providersStore.getProviderInstance<TranscriptionProviderWithExtraOptions<string, any>>(providerId)
if (!provider)
throw new Error('Failed to initialize transcription provider')
return await hearingStore.transcription(
providerId,
provider,
model.value,
file,
'json',
)
}
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="mimo_... (MiMo API key)"
/>
<FieldCombobox
v-model="model"
:label="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name')"
:options="providerModels.map(m => ({ value: m.id, label: m.name }))"
:disabled="isLoadingModels || providerModels.length === 0"
placeholder="Select a model..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
:placeholder="providerMetadata?.defaultOptions?.().baseUrl as string || 'https://api.xiaomimimo.com/v1/'"
/>
</ProviderAdvancedSettings>
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
</ProviderSettingsContainer>
<TranscriptionPlayground
:generate-transcription="handleGenerateTranscription"
:api-key-configured="apiKeyConfigured"
/>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -26,6 +26,7 @@ import './mistral-ai'
import './moonshot-ai'
import './modelscope'
import './ollama'
import './mimo'
import './cloudflare-workers-ai'
import './azure-ai-foundry'
import './official'
@@ -0,0 +1,54 @@
import { createXiaomi } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const mimoConfigSchema = z.object({
apiKey: z
.string('API Key'),
baseUrl: z
.string('Base URL')
.optional()
.default('https://api.xiaomimimo.com/v1/'),
})
type MimoConfig = z.input<typeof mimoConfigSchema>
export const providerMimo = defineProvider<MimoConfig>({
id: 'mimo',
order: 4,
name: 'Xiaomi MiMo',
nameLocalize: ({ t }) => t('settings.pages.providers.provider.mimo.title'),
description: 'api.xiaomimimo.com',
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.mimo.description'),
tasks: ['chat'],
icon: 'i-simple-icons:xiaomi',
createProviderConfig: ({ t }) => mimoConfigSchema.extend({
apiKey: mimoConfigSchema.shape.apiKey.meta({
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'),
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'),
type: 'password',
}),
baseUrl: mimoConfigSchema.shape.baseUrl.meta({
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'),
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
}),
}),
createProvider(config) {
return createXiaomi(config.apiKey, config.baseUrl)
},
validationRequiredWhen(config) {
return !!config.apiKey?.trim()
},
validators: {
...createOpenAICompatibleValidators({
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList, ProviderValidationCheck.ChatCompletions],
}),
},
})
+317
View File
@@ -1555,6 +1555,173 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
'openrouter-audio-speech': buildOpenRouterAudioSpeechProvider(v => baseUrlValidator.value(v)),
'mimo-audio-speech': {
id: 'mimo-audio-speech',
category: 'speech',
tasks: ['text-to-speech'],
nameKey: 'settings.pages.providers.provider.mimo.title',
name: 'Xiaomi MiMo',
descriptionKey: 'settings.pages.providers.provider.mimo.description',
description: 'api.xiaomimimo.com',
icon: 'i-simple-icons:xiaomi',
defaultOptions: () => ({
baseUrl: 'https://api.xiaomimimo.com/v1/',
model: 'mimo-v2.5-tts',
voice: 'mimo_default',
format: 'wav',
}),
createProvider: async (config) => {
const apiKey = (config.apiKey as string)?.trim() ?? ''
const baseUrl = ((config.baseUrl as string) || 'https://api.xiaomimimo.com/v1/').replace(/\/+$/, '')
const defaultModel = (config.model as string) || 'mimo-v2.5-tts'
const defaultVoice = (config.voice as string) || 'mimo_default'
const defaultFormat = (config.format as string) || 'wav'
const provider: SpeechProvider = {
speech: () => ({
baseURL: `${baseUrl}/`,
model: defaultModel,
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
if (!init?.body || typeof init.body !== 'string') {
throw new Error('Invalid request body')
}
const body = JSON.parse(init.body)
const text = body.input as string
const modelId = (body.model as string) || defaultModel
const format = (body.response_format as string) || defaultFormat
const stylePrompt = typeof body.style_prompt === 'string'
? body.style_prompt.trim()
: typeof config.stylePrompt === 'string'
? config.stylePrompt.trim()
: ''
const voiceSample = typeof body.voice_sample === 'string'
? body.voice_sample.trim()
: typeof config.voiceSample === 'string'
? config.voiceSample.trim()
: ''
const userPrompt = modelId === 'mimo-v2.5-tts-voiceclone'
? stylePrompt
: stylePrompt || 'Use a natural, clear speaking style.'
const audio: Record<string, string> = { format }
if (modelId === 'mimo-v2.5-tts-voiceclone') {
if (!voiceSample) {
throw new Error('MiMo voice clone requires a base64 audio sample in data URI format.')
}
audio.voice = voiceSample
}
else if (modelId === 'mimo-v2.5-tts') {
audio.voice = (body.voice as string) || defaultVoice
}
if (modelId === 'mimo-v2.5-tts-voicedesign' && !stylePrompt) {
throw new Error('MiMo voice design requires a style prompt in the user message.')
}
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
},
body: JSON.stringify({
model: modelId,
messages: [
{ role: 'user', content: userPrompt },
{ role: 'assistant', content: text },
],
audio,
}),
})
if (!response.ok || !response.body) {
throw new Error(`MiMo TTS request failed: ${response.status} ${response.statusText}`)
}
const data = await response.json()
const audioBase64 = data?.choices?.[0]?.message?.audio?.data
if (!audioBase64) {
throw new Error('MiMo TTS response missing audio data')
}
const binaryString = atob(audioBase64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
const contentType = format === 'wav' ? 'audio/wav' : format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
return new Response(bytes.buffer, {
status: 200,
headers: { 'Content-Type': contentType },
})
},
}),
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'mimo-v2.5-tts',
name: 'MiMo v2.5 TTS',
provider: 'mimo-audio-speech',
description: 'Preset voice synthesis with the built-in MiMo voice list',
contextLength: 0,
deprecated: false,
},
{
id: 'mimo-v2.5-tts-voicedesign',
name: 'MiMo v2.5 TTS Voice Design',
provider: 'mimo-audio-speech',
description: 'Design a new voice from a natural language description',
contextLength: 0,
deprecated: false,
},
{
id: 'mimo-v2.5-tts-voiceclone',
name: 'MiMo v2.5 TTS Voice Clone',
provider: 'mimo-audio-speech',
description: 'Clone a voice from a base64-encoded audio sample',
contextLength: 0,
deprecated: false,
},
],
listVoices: async () => [
{ id: 'mimo_default', name: 'MiMo-默认', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }, { code: 'zh', title: 'Chinese' }] },
{ id: '冰糖', name: '冰糖', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'zh', title: 'Chinese' }] },
{ id: '茉莉', name: '茉莉', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'zh', title: 'Chinese' }] },
{ id: '苏打', name: '苏打', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'zh', title: 'Chinese' }] },
{ id: '白桦', name: '白桦', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'zh', title: 'Chinese' }] },
{ id: 'Mia', name: 'Mia', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }] },
{ id: 'Chloe', name: 'Chloe', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }] },
{ id: 'Milo', name: 'Milo', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'en', title: 'English' }] },
{ id: 'Dean', name: 'Dean', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'en', title: 'English' }] },
],
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API key is required.'),
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
const res = baseUrlValidator.value(config.baseUrl)
if (res) {
return res
}
return {
errors,
reason: errors.map(e => (e as Error).message).join(', ') || '',
valid: !!config.apiKey && !!config.baseUrl,
}
},
},
},
'comet-api-speech': buildOpenAICompatibleProvider({
id: 'comet-api-speech',
name: 'CometAPI Speech',
@@ -1587,6 +1754,156 @@ export const useProvidersStore = defineStore('providers', () => {
),
validation: [ProviderValidationCheck.ModelList],
}),
'mimo-audio-transcription': {
id: 'mimo-audio-transcription',
category: 'transcription',
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
nameKey: 'settings.pages.providers.provider.mimo.title',
name: 'Xiaomi MiMo',
descriptionKey: 'settings.pages.providers.provider.mimo.description',
description: 'api.xiaomimimo.com',
icon: 'i-simple-icons:xiaomi',
defaultOptions: () => ({
baseUrl: 'https://api.xiaomimimo.com/v1/',
model: 'mimo-v2-omni',
}),
createProvider: async (config) => {
const apiKey = (config.apiKey as string)?.trim() ?? ''
const rawBaseUrl = `${((config.baseUrl as string) || 'https://api.xiaomimimo.com/v1/').replace(/\/+$/, '')}/`
const defaultModel = (config.model as string) || 'mimo-v2-omni'
const provider: TranscriptionProvider = {
transcription: model => ({
baseURL: rawBaseUrl,
model: model || defaultModel,
headers: {},
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
const formData = init?.body as FormData
const file = formData?.get('file') as Blob | null
const modelName = (formData?.get('model') as string) || defaultModel
if (!file) {
throw new Error('No audio file provided for transcription.')
}
// Read the file as base64 data URI (works with both Blob and File)
const base64DataUri: string = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error('Failed to read audio file'))
reader.readAsDataURL(file)
})
// Extract format and base64 data from data URI
// data:audio/wav;base64,UklGR...
const mimeType = base64DataUri.split(';')[0]?.split(':')[1] || 'audio/wav'
const formatFromMime = mimeType.split('/')[1] || 'wav'
const base64Data = base64DataUri.split(',')[1]
// Map MIME sub-type to MiMo supported audio format
const audioFormat = formatFromMime === 'webm'
? 'webm'
: formatFromMime === 'mp4'
? 'mp4'
: formatFromMime === 'mpeg' || formatFromMime === 'mp3'
? 'mp3'
: 'wav'
// MiMo audio understanding uses chat completions with input_audio,
// not a dedicated transcription endpoint
const response = await fetch(`${rawBaseUrl}chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
},
body: JSON.stringify({
model: modelName,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Transcribe the audio content.' },
{
type: 'input_audio',
input_audio: {
data: base64Data,
format: audioFormat,
},
},
],
},
],
}),
})
if (!response.ok) {
const errorBody = await response.text().catch(() => '')
throw new Error(
`MiMo transcription failed: ${response.status} ${response.statusText}${errorBody ? `${errorBody}` : ''}`,
)
}
const data = await response.json()
const text = data?.choices?.[0]?.message?.content || ''
// Return in OpenAI transcription response format { text: "..." }
return new Response(JSON.stringify({ text }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
},
}),
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'mimo-v2-omni',
name: 'MiMo V2 Omni',
provider: 'mimo-audio-transcription',
description: 'Omni-modal model with native audio understanding and speech-to-text',
contextLength: 256000,
deprecated: false,
},
{
id: 'mimo-v2.5',
name: 'MiMo V2.5',
provider: 'mimo-audio-transcription',
description: 'Latest omni-modal model with audio understanding, 1M context',
contextLength: 1_000_000,
deprecated: false,
},
],
},
transcriptionFeatures: {
supportsGenerate: true,
supportsStreamOutput: false,
supportsStreamInput: false,
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API key is required.'),
!config.baseUrl && new Error('Base URL is required.'),
].filter(Boolean)
const res = baseUrlValidator.value(config.baseUrl)
if (res) {
return res
}
return {
errors,
reason: errors.map(e => (e as Error).message).join(', ') || '',
valid: !!config.apiKey && !!config.baseUrl,
}
},
},
},
'player2-speech': {
id: 'player2-speech',
category: 'speech',