mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
feat(providers): add Xiaomi Mimo (#1760)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
## このプロジェクトから生まれたサブプロジェクト
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 이 프로젝트에서 탄생한 하위 프로젝트
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
## Подпроекты, родившиеся из этого проекта
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
+162
@@ -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],
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user