feat: Add generic OpenAI-compatible provider for chat and audio (#415)
This commit introduces a new "OpenAI Compatible" provider, allowing users to connect to any service that implements the OpenAI API specification. Previously, users were limited to the pre-defined list of providers. With the proliferation of many new AI services and local models (like LM Studio, vLLM, etc.) that expose an OpenAI-compatible endpoint, it has become impractical to add and maintain a separate integration for each one. This new generic provider solves that problem by offering a single, flexible solution. It clarifies the user's choice by separating the official OpenAI service from other compatible alternatives, improving the overall user experience. Closes #401, #388 Co-authored-by: Neko <neko@ayaka.moe> --------- Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
FieldRange,
|
||||
Textarea,
|
||||
} from '@proj-airi/ui'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { generateSpeech } from '@xsai/generate-speech'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -59,9 +60,19 @@ onMounted(async () => {
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
|
||||
})
|
||||
|
||||
watch(activeSpeechProvider, async () => {
|
||||
watchDebounced(activeSpeechProvider, async () => {
|
||||
await providersStore.loadModelsForConfiguredProviders()
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
|
||||
}, { debounce: 100 })
|
||||
|
||||
watch(activeSpeechVoiceId, (newId) => {
|
||||
if (newId) {
|
||||
const voices = availableVoices.value[activeSpeechProvider.value] || []
|
||||
const existingVoice = voices.find(voice => voice.id === newId)
|
||||
if (!existingVoice) {
|
||||
updateCustomVoiceName(newId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Function to generate speech
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import type { RemovableRef } from '@vueuse/core'
|
||||
import type { SpeechProvider } from '@xsai-ext/shared-providers'
|
||||
|
||||
import OpenAICompatibleSpeechPlayground from '@proj-airi/stage-ui/components/Scenarios/Providers/OpenAICompatibleSpeechPlayground.vue'
|
||||
|
||||
import {
|
||||
ProviderAdvancedSettings,
|
||||
ProviderApiKeyInput,
|
||||
ProviderBaseUrlInput,
|
||||
ProviderBasicSettings,
|
||||
ProviderSettingsContainer,
|
||||
ProviderSettingsLayout,
|
||||
} from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
|
||||
import { FieldRange } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const defaultVoiceSettings = {
|
||||
speed: 1.0,
|
||||
}
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'openai-compatible-audio-speech'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
|
||||
// Settings refs
|
||||
const apiKey = computed({
|
||||
get: () => providers.value[providerId]?.apiKey || '',
|
||||
set: (value) => {
|
||||
if (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].baseUrl = value
|
||||
},
|
||||
})
|
||||
|
||||
const model = computed({
|
||||
get: () => providers.value[providerId]?.model || 'tts-1',
|
||||
set: (value) => {
|
||||
if (providers.value[providerId])
|
||||
providers.value[providerId].model = value
|
||||
},
|
||||
})
|
||||
|
||||
const voice = computed({
|
||||
get: () => providers.value[providerId]?.voice || 'alloy',
|
||||
set: (value) => {
|
||||
if (providers.value[providerId])
|
||||
providers.value[providerId].voice = value
|
||||
},
|
||||
})
|
||||
|
||||
const speed = ref<number>(1.0)
|
||||
|
||||
// Check if API key is configured
|
||||
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
|
||||
|
||||
// Generate speech with specific parameters
|
||||
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)
|
||||
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
modelId || model.value,
|
||||
input,
|
||||
voiceId || voice.value,
|
||||
{
|
||||
...providerConfig,
|
||||
...defaultVoiceSettings,
|
||||
speed: speed.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
const config = providers.value[providerId] || {}
|
||||
apiKey.value = config.apiKey || ''
|
||||
baseUrl.value = config.baseUrl || ''
|
||||
model.value = config.model || 'tts-1'
|
||||
voice.value = config.voice || 'alloy'
|
||||
speed.value = config.speed || 1.0
|
||||
})
|
||||
|
||||
watch(speed, (newSpeed) => {
|
||||
if (providers.value[providerId])
|
||||
providers.value[providerId].speed = newSpeed
|
||||
})
|
||||
|
||||
function handleResetSettings() {
|
||||
const defaults = providerMetadata.value?.defaultOptions?.() || {}
|
||||
providers.value[providerId] = {
|
||||
apiKey: '',
|
||||
baseUrl: defaults.baseUrl || '',
|
||||
model: 'tts-1',
|
||||
voice: 'alloy',
|
||||
speed: 1.0,
|
||||
}
|
||||
// Force update refs
|
||||
apiKey.value = ''
|
||||
baseUrl.value = defaults.baseUrl || ''
|
||||
model.value = 'tts-1'
|
||||
voice.value = 'alloy'
|
||||
speed.value = 1.0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName || 'OpenAI Compatible'"
|
||||
:provider-icon="providerMetadata?.icon"
|
||||
: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="sk-..."
|
||||
/>
|
||||
</ProviderBasicSettings>
|
||||
|
||||
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
|
||||
<ProviderBaseUrlInput
|
||||
v-model="baseUrl"
|
||||
placeholder="https://api.example.com/v1/"
|
||||
/>
|
||||
<FieldRange
|
||||
v-model="speed"
|
||||
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
|
||||
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
|
||||
:min="0.5"
|
||||
:max="2.0" :step="0.01"
|
||||
/>
|
||||
</ProviderAdvancedSettings>
|
||||
</ProviderSettingsContainer>
|
||||
|
||||
<OpenAICompatibleSpeechPlayground
|
||||
v-model:model-value="model"
|
||||
v-model:voice="voice"
|
||||
:generate-speech="handleGenerateSpeech"
|
||||
:api-key-configured="apiKeyConfigured"
|
||||
default-text="Hello! This is a test of the OpenAI Compatible Speech."
|
||||
/>
|
||||
</ProviderSettingsLayout>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import type { RemovableRef } from '@vueuse/core'
|
||||
import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
|
||||
|
||||
import {
|
||||
ProviderAdvancedSettings,
|
||||
ProviderApiKeyInput,
|
||||
ProviderBaseUrlInput,
|
||||
ProviderBasicSettings,
|
||||
ProviderSettingsContainer,
|
||||
ProviderSettingsLayout,
|
||||
TranscriptionPlayground,
|
||||
} from '@proj-airi/stage-ui/components'
|
||||
import { useHearingStore, useProvidersStore } from '@proj-airi/stage-ui/stores'
|
||||
import { FieldInput } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const hearingStore = useHearingStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'openai-compatible-audio-transcription'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
const pageTitle = computed(() => providerMetadata.value?.localizedName || t('settings.pages.providers.provider.openai-compatible-audio-transcription.title'))
|
||||
|
||||
// Settings refs
|
||||
const apiKey = computed({
|
||||
get: () => providers.value[providerId]?.apiKey || '',
|
||||
set: value => (providers.value[providerId] = { ...providers.value[providerId], apiKey: value }),
|
||||
})
|
||||
|
||||
const baseUrl = computed({
|
||||
get: () => providers.value[providerId]?.baseUrl || '',
|
||||
set: value => (providers.value[providerId] = { ...providers.value[providerId], baseUrl: value }),
|
||||
})
|
||||
|
||||
const model = computed({
|
||||
get: () => providers.value[providerId]?.model || 'whisper-1',
|
||||
set: value => (providers.value[providerId] = { ...providers.value[providerId], model: value }),
|
||||
})
|
||||
|
||||
// Check if API key is configured
|
||||
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
|
||||
|
||||
// Generate transcription
|
||||
async function handleGenerateTranscription(file: File) {
|
||||
const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(providerId)
|
||||
if (!provider)
|
||||
throw new Error('Failed to initialize transcription provider')
|
||||
|
||||
return await hearingStore.transcription(
|
||||
provider,
|
||||
model.value,
|
||||
file,
|
||||
'json',
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
const config = providers.value[providerId] || {}
|
||||
apiKey.value = config.apiKey || ''
|
||||
baseUrl.value = config.baseUrl || ''
|
||||
model.value = config.model || 'whisper-1'
|
||||
})
|
||||
|
||||
function handleResetSettings() {
|
||||
const defaults = providerMetadata.value?.defaultOptions?.() || {}
|
||||
providers.value[providerId] = {
|
||||
apiKey: '',
|
||||
baseUrl: defaults.baseUrl || '',
|
||||
model: 'whisper-1',
|
||||
}
|
||||
// Force update refs
|
||||
apiKey.value = ''
|
||||
baseUrl.value = defaults.baseUrl || ''
|
||||
model.value = 'whisper-1'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="pageTitle"
|
||||
:provider-icon="providerMetadata?.icon"
|
||||
: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="sk-..."
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="model"
|
||||
:label="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name')"
|
||||
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
|
||||
/>
|
||||
</ProviderBasicSettings>
|
||||
|
||||
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
|
||||
<ProviderBaseUrlInput
|
||||
v-model="baseUrl"
|
||||
placeholder="https://api.example.com/v1/"
|
||||
/>
|
||||
</ProviderAdvancedSettings>
|
||||
</ProviderSettingsContainer>
|
||||
|
||||
<template #playground>
|
||||
<TranscriptionPlayground
|
||||
:generate-transcription="handleGenerateTranscription"
|
||||
:api-key-configured="apiKeyConfigured"
|
||||
/>
|
||||
</template>
|
||||
</ProviderSettingsLayout>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import type { RemovableRef } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
ProviderAdvancedSettings,
|
||||
ProviderApiKeyInput,
|
||||
ProviderBaseUrlInput,
|
||||
ProviderBasicSettings,
|
||||
ProviderSettingsContainer,
|
||||
ProviderSettingsLayout,
|
||||
} from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'openai-compatible'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
|
||||
// Use computed properties for settings
|
||||
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 || providerMetadata.value?.defaultOptions?.().baseUrl || '',
|
||||
set: (value) => {
|
||||
if (!providers.value[providerId])
|
||||
providers.value[providerId] = {}
|
||||
|
||||
providers.value[providerId].baseUrl = value
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[providerId]?.apiKey || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.().baseUrl || ''
|
||||
})
|
||||
|
||||
// Watch settings and update the provider configuration
|
||||
watch([apiKey, baseUrl], () => {
|
||||
providers.value[providerId] = {
|
||||
...providers.value[providerId],
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.().baseUrl || '',
|
||||
}
|
||||
})
|
||||
|
||||
function handleResetSettings() {
|
||||
providers.value[providerId] = {
|
||||
...(providerMetadata.value?.defaultOptions as any),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName || 'OpenAI Compatible'"
|
||||
:provider-icon="providerMetadata?.icon"
|
||||
: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="sk-..."
|
||||
/>
|
||||
</ProviderBasicSettings>
|
||||
|
||||
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
|
||||
<ProviderBaseUrlInput
|
||||
v-model="baseUrl"
|
||||
:placeholder="providerMetadata?.defaultOptions?.().baseUrl as string || 'https://api.example.com/v1/'"
|
||||
/>
|
||||
</ProviderAdvancedSettings>
|
||||
</ProviderSettingsContainer>
|
||||
</ProviderSettingsLayout>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
@@ -423,6 +423,9 @@ pages:
|
||||
openai:
|
||||
description: openai.com
|
||||
title: OpenAI
|
||||
openai-compatible:
|
||||
description: OpenAI Compatible
|
||||
title: OpenAI Compatible
|
||||
openrouter:
|
||||
description: openrouter.ai
|
||||
title: OpenRouter
|
||||
|
||||
@@ -422,6 +422,9 @@ pages:
|
||||
openai:
|
||||
description: openai.com
|
||||
title: OpenAI
|
||||
openai-compatible:
|
||||
description: OpenAI Compatible
|
||||
title: OpenAI Compatible
|
||||
openrouter:
|
||||
description: openrouter.ai
|
||||
title: OpenRouter
|
||||
|
||||
@@ -392,6 +392,9 @@ pages:
|
||||
openai:
|
||||
description: OpenAi.com
|
||||
title: OpenAI
|
||||
openai-compatible:
|
||||
description: OpenAI Compatible
|
||||
title: OpenAI Compatible
|
||||
openrouter:
|
||||
description: OpenRouter.ai
|
||||
title: OpenRouter
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { FieldCheckbox, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { TestDummyMarker } from '../../Gadgets'
|
||||
|
||||
const props = defineProps<{
|
||||
// Input fields
|
||||
defaultText?: string
|
||||
modelValue: string
|
||||
voice: string
|
||||
|
||||
// Provider-specific handlers (provided from parent)
|
||||
generateSpeech: (input: string, voice: string, useSSML: boolean, model?: string) => Promise<ArrayBuffer>
|
||||
|
||||
// Current state
|
||||
apiKeyConfigured?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:voice'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const voice = computed({
|
||||
get: () => props.voice,
|
||||
set: value => emit('update:voice', value),
|
||||
})
|
||||
|
||||
// Playground state
|
||||
const testText = ref(props.defaultText || 'Hello! This is a test of the voice synthesis.')
|
||||
const isGenerating = ref(false)
|
||||
const audioUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
const useSSML = ref(false)
|
||||
const ssmlText = ref('')
|
||||
|
||||
// Function to generate speech
|
||||
async function handleGenerateTestSpeech() {
|
||||
if ((!testText.value.trim() && !useSSML.value) || (useSSML.value && !ssmlText.value.trim()))
|
||||
return
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
}
|
||||
|
||||
const input = useSSML.value ? ssmlText.value : testText.value
|
||||
|
||||
const response = await props.generateSpeech(input, voice.value, useSSML.value, model.value)
|
||||
|
||||
// Convert the response to a blob and create an object URL
|
||||
audioUrl.value = URL.createObjectURL(new Blob([response]))
|
||||
|
||||
// Play the audio
|
||||
setTimeout(() => {
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.play()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error generating speech:', error)
|
||||
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
|
||||
}
|
||||
finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Function to stop audio playback
|
||||
function stopTestAudio() {
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.pause()
|
||||
audioPlayer.value.currentTime = 0
|
||||
}
|
||||
|
||||
// Clean up the object URL to prevent memory leaks
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value)
|
||||
audioUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up when component is unmounted
|
||||
onUnmounted(() => {
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Expose public methods and state
|
||||
defineExpose({
|
||||
testText,
|
||||
ssmlText,
|
||||
useSSML,
|
||||
isGenerating,
|
||||
audioUrl,
|
||||
errorMessage,
|
||||
audioPlayer,
|
||||
generateTestSpeech: handleGenerateTestSpeech,
|
||||
stopTestAudio,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div w-full rounded-xl>
|
||||
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
|
||||
<div class="inline-flex items-center gap-4">
|
||||
<TestDummyMarker />
|
||||
<div>
|
||||
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div flex="~ col gap-4">
|
||||
<FieldInput
|
||||
v-model="model"
|
||||
label="Model ID"
|
||||
placeholder="tts-1"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="voice"
|
||||
label="Voice"
|
||||
placeholder="alloy"
|
||||
/>
|
||||
<FieldCheckbox
|
||||
v-model="useSSML"
|
||||
:label="t('settings.pages.modules.speech.sections.section.voice-settings.use-ssml.label')"
|
||||
:description="t('settings.pages.modules.speech.sections.section.voice-settings.use-ssml.description')"
|
||||
/>
|
||||
|
||||
<template v-if="!useSSML">
|
||||
<textarea
|
||||
v-model="testText"
|
||||
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
|
||||
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
|
||||
transition="all duration-250 ease-in-out"
|
||||
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
|
||||
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<textarea
|
||||
v-model="ssmlText"
|
||||
:placeholder="t('settings.pages.modules.speech.sections.section.voice-settings.input-ssml.placeholder')"
|
||||
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
|
||||
transition="all duration-250 ease-in-out"
|
||||
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
|
||||
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Playground actions -->
|
||||
<div flex="~ row" gap-4>
|
||||
<button
|
||||
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
|
||||
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
|
||||
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKeyConfigured"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKeyConfigured }"
|
||||
bg="neutral-700 dark:neutral-300" @click="handleGenerateTestSpeech"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<div i-solar:play-circle-bold-duotone />
|
||||
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
|
||||
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<div i-solar:stop-circle-bold-duotone />
|
||||
<span>{{ t('settings.pages.modules.speech.sections.section.playground.buttons.stop.label') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Error messages -->
|
||||
<div v-if="!apiKeyConfigured" class="mt-2 text-sm text-red-500">
|
||||
{{ t('settings.pages.providers.provider.elevenlabs.playground.validation.error-missing-api-key') }}
|
||||
</div>
|
||||
<div v-if="errorMessage" class="mt-2 text-sm text-red-500">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
|
||||
</div>
|
||||
<!-- Slot for additional provider-specific UI in the playground -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,7 +16,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
|
||||
|
||||
// Check if any essential provider is configured
|
||||
const hasEssentialProviderConfigured = computed(() => {
|
||||
const essentialProviders = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek']
|
||||
const essentialProviders = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'openai-compatible']
|
||||
return essentialProviders.some(providerId => providersStore.configuredProviders[providerId])
|
||||
})
|
||||
|
||||
|
||||
@@ -812,6 +812,54 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
'openai-compatible': {
|
||||
id: 'openai-compatible',
|
||||
category: 'chat',
|
||||
tasks: ['text-generation'],
|
||||
nameKey: 'settings.pages.providers.provider.openai-compatible.title',
|
||||
name: 'OpenAI Compatible',
|
||||
descriptionKey: 'settings.pages.providers.provider.openai-compatible.description',
|
||||
description: 'Connect to any API that follows the OpenAI specification.',
|
||||
icon: 'i-lobe-icons:openai',
|
||||
defaultOptions: () => ({
|
||||
baseUrl: '',
|
||||
}),
|
||||
createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()),
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'openai-compatible',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
validators: {
|
||||
validateProviderConfig: (config) => {
|
||||
const errors = [
|
||||
!config.apiKey && new Error('API key is required'),
|
||||
!config.baseUrl && new Error('Base URL is required'),
|
||||
].filter(Boolean)
|
||||
|
||||
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
|
||||
return notBaseUrlError.value
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
|
||||
valid: !!config.apiKey && !!config.baseUrl,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
'openai-audio-speech': {
|
||||
id: 'openai-audio-speech',
|
||||
category: 'speech',
|
||||
@@ -929,6 +977,57 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
'openai-compatible-audio-speech': {
|
||||
id: 'openai-compatible-audio-speech',
|
||||
category: 'speech',
|
||||
tasks: ['text-to-speech'],
|
||||
nameKey: 'settings.pages.providers.provider.openai-compatible.title',
|
||||
name: 'OpenAI Compatible',
|
||||
descriptionKey: 'settings.pages.providers.provider.openai-compatible.description',
|
||||
description: 'Connect to any API that follows the OpenAI specification.',
|
||||
icon: 'i-lobe-icons:openai',
|
||||
defaultOptions: () => ({
|
||||
baseUrl: '',
|
||||
}),
|
||||
createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()),
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'openai-compatible-audio-speech',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
listVoices: async () => {
|
||||
return []
|
||||
},
|
||||
},
|
||||
validators: {
|
||||
validateProviderConfig: (config) => {
|
||||
const errors = [
|
||||
!config.apiKey && new Error('API key is required'),
|
||||
!config.baseUrl && new Error('Base URL is required'),
|
||||
].filter(Boolean)
|
||||
|
||||
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
|
||||
return notBaseUrlError.value
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
|
||||
valid: !!config.apiKey && !!config.baseUrl,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
'openai-audio-transcription': {
|
||||
id: 'openai-audio-transcription',
|
||||
category: 'transcription',
|
||||
@@ -976,6 +1075,54 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
'openai-compatible-audio-transcription': {
|
||||
id: 'openai-compatible-audio-transcription',
|
||||
category: 'transcription',
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
|
||||
nameKey: 'settings.pages.providers.provider.openai-compatible.title',
|
||||
name: 'OpenAI Compatible',
|
||||
descriptionKey: 'settings.pages.providers.provider.openai-compatible.description',
|
||||
description: 'Connect to any API that follows the OpenAI specification.',
|
||||
icon: 'i-lobe-icons:openai',
|
||||
defaultOptions: () => ({
|
||||
baseUrl: '',
|
||||
}),
|
||||
createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()),
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'openai-compatible-audio-transcription',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
validators: {
|
||||
validateProviderConfig: (config) => {
|
||||
const errors = [
|
||||
!config.apiKey && new Error('API key is required'),
|
||||
!config.baseUrl && new Error('Base URL is required'),
|
||||
].filter(Boolean)
|
||||
|
||||
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
|
||||
return notBaseUrlError.value
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
|
||||
valid: !!config.apiKey && !!config.baseUrl,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
'azure-ai-foundry': {
|
||||
id: 'azure-ai-foundry',
|
||||
category: 'chat',
|
||||
|
||||
Reference in New Issue
Block a user