feat(stage-web|stage-tamagotchi): playground, better speech config
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Voice } from '@proj-airi/stage-ui/constants'
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
|
||||
|
||||
import { Collapsable } from '@proj-airi/stage-ui/components'
|
||||
import { voiceMap } from '@proj-airi/stage-ui/constants'
|
||||
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { generateSpeech } from '@xsai/generate-speech'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -11,6 +16,13 @@ const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
// For playground
|
||||
const testText = ref('Hello! This is a test of the ElevenLabs voice synthesis.')
|
||||
const isGenerating = ref(false)
|
||||
const audioUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'elevenlabs'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
@@ -18,6 +30,13 @@ const providerMetadata = computed(() => providersStore.getProviderMetadata(provi
|
||||
const apiKey = ref(providers.value[providerId]?.apiKey || '')
|
||||
const baseUrl = ref(providers.value[providerId]?.baseUrl || '')
|
||||
|
||||
// Voice settings as individual refs
|
||||
const similarityBoost = ref((providers.value[providerId]?.voiceSettings as any)?.similarityBoost || 0.75)
|
||||
const stability = ref((providers.value[providerId]?.voiceSettings as any)?.stability || 0.5)
|
||||
const speed = ref((providers.value[providerId]?.voiceSettings as any)?.speed || 1.0)
|
||||
const style = ref((providers.value[providerId]?.voiceSettings as any)?.style || 0)
|
||||
const useSpeakerBoost = ref((providers.value[providerId]?.voiceSettings as any)?.useSpeakerBoost !== false)
|
||||
|
||||
// Speech settings
|
||||
const selectedLanguage = ref(speechStore.selectedLanguage)
|
||||
const selectedVoice = ref(speechStore.voiceName)
|
||||
@@ -31,7 +50,16 @@ onMounted(() => {
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[providerId]?.apiKey || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.baseUrlDefault || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || ''
|
||||
|
||||
// Initialize voice settings refs
|
||||
if (providers.value[providerId]?.voiceSettings) {
|
||||
similarityBoost.value = (providers.value[providerId].voiceSettings as any)?.similarityBoost ?? 0.75
|
||||
stability.value = (providers.value[providerId].voiceSettings as any)?.stability ?? 0.5
|
||||
speed.value = (providers.value[providerId].voiceSettings as any)?.speed ?? 1.0
|
||||
style.value = (providers.value[providerId].voiceSettings as any)?.style ?? 0
|
||||
useSpeakerBoost.value = (providers.value[providerId].voiceSettings as any)?.useSpeakerBoost !== false
|
||||
}
|
||||
|
||||
// Load voices if provider is configured
|
||||
if (providersStore.configuredProviders[providerId]) {
|
||||
@@ -39,10 +67,19 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([apiKey, baseUrl], () => {
|
||||
// Watch all settings and update the provider configuration
|
||||
watch([apiKey, baseUrl, similarityBoost, stability, speed, style, useSpeakerBoost], () => {
|
||||
providers.value[providerId] = {
|
||||
...providers.value[providerId],
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.baseUrlDefault || '',
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl || '',
|
||||
voiceSettings: {
|
||||
similarityBoost: similarityBoost.value,
|
||||
stability: stability.value,
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,6 +90,80 @@ watch(selectedLanguage, (newLanguage) => {
|
||||
watch(selectedVoice, (newVoice) => {
|
||||
speechStore.setVoiceName(newVoice)
|
||||
})
|
||||
|
||||
// Function to generate speech
|
||||
async function generateTestSpeech() {
|
||||
if (!testText.value.trim())
|
||||
return
|
||||
|
||||
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, any>
|
||||
if (!provider) {
|
||||
console.error('Failed to initialize speech provider')
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
}
|
||||
|
||||
const response = await generateSpeech({
|
||||
...provider.speech('eleven_multilingual_v2', {
|
||||
voiceSettings: {
|
||||
stability: stability.value,
|
||||
similarityBoost: similarityBoost.value,
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}),
|
||||
input: testText.value,
|
||||
voice: voiceMap[selectedVoice.value as Voice],
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,201 +180,250 @@ watch(selectedVoice, (newVoice) => {
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-6">
|
||||
<div>
|
||||
<div flex="~ col gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Basic
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Essential settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
API Key
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400" text-nowrap>
|
||||
API Key for {{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKey" type="password"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="..."
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ col gap-6">
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Voice Settings
|
||||
</h2>
|
||||
<div flex="~ col gap-6">
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div flex="~ col md:row gap-6">
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-6" w="full md:40%">
|
||||
<div>
|
||||
<div flex="~ col gap-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Language
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select voice language
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedLanguage"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="language in speechStore.availableLanguages" :key="language" :value="language">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Voice
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select preferred voice
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedVoice"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="voice in availableVoices" :key="voice.id" :value="voice.name">
|
||||
{{ voice.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Pitch
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Adjust voice pitch
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input
|
||||
v-model="speechStore.pitch"
|
||||
type="range"
|
||||
min="-100"
|
||||
max="100"
|
||||
step="1"
|
||||
w-full
|
||||
>
|
||||
<span class="text-xs">{{ speechStore.pitch }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Rate
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Adjust speaking rate
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input
|
||||
v-model="speechStore.rate"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
w-full
|
||||
>
|
||||
<span class="text-xs">{{ speechStore.rate.toFixed(1) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
SSML
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Enable SSML support
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
v-model="speechStore.ssmlEnabled"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
:disabled="!speechStore.supportsSSML"
|
||||
>
|
||||
<span class="text-sm">{{ speechStore.ssmlEnabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Collapsable w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="() => slotProps.setVisible(!slotProps.visible) && toggleAdvancedVisible()"
|
||||
>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
<span>Advanced</span>
|
||||
Basic
|
||||
</h2>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Essential settings</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div mt-4>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
API Key
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400" text-nowrap>
|
||||
API Key for {{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKey" type="password"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="..."
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ col gap-6">
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Voice Settings
|
||||
</h2>
|
||||
<div flex="~ col gap-6">
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Base URL
|
||||
Similarity Boost
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Custom base URL (optional)
|
||||
Voice similarity adherence
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="baseUrl" type="text"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.baseUrlDefault"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="similarityBoost" type="range" min="0" max="1" step="0.01" w-full>
|
||||
<span class="text-xs">{{ similarityBoost.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div mt-4>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Stability
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Voice stability and randomness
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="stability" type="range" min="0" max="1" step="0.01" w-full>
|
||||
<span class="text-xs">{{ stability.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Speed
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Speech generation speed
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="speed" type="range" min="0.7" max="1.2" step="0.01" w-full>
|
||||
<span class="text-xs">{{ speed.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Style
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Voice style exaggeration
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="style" type="range" min="0" max="1" step="0.01" w-full>
|
||||
<span class="text-xs">{{ style.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Speaker Boost
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Enhance speaker similarity
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="useSpeakerBoost" type="checkbox">
|
||||
<span class="text-xs">{{ useSpeakerBoost ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Collapsable w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
border="zinc-300 dark:zinc-800 solid 1"
|
||||
transition="border duration-250 ease-in-out"
|
||||
rounded
|
||||
px-4 py-2 text-sm @click="speechStore.resetVoiceSettings"
|
||||
transition="all ease-in-out duration-250" w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="() => slotProps.setVisible(!slotProps.visible) && toggleAdvancedVisible()"
|
||||
>
|
||||
Reset Voice Settings
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
<span>Advanced</span>
|
||||
</h2>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div mt-4>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Base URL
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Custom base URL (optional)
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="baseUrl" type="text"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div mt-4>
|
||||
<button
|
||||
border="zinc-300 dark:zinc-800 solid 1" transition="border duration-250 ease-in-out" rounded px-4
|
||||
py-2 text-sm @click="speechStore.resetVoiceSettings"
|
||||
>
|
||||
Reset Voice Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ col gap-6" w="full md:60%">
|
||||
<div rounded-xl>
|
||||
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Voice Playground
|
||||
</h2>
|
||||
<div flex="~ col gap-4">
|
||||
<textarea
|
||||
v-model="testText" placeholder="Enter text to test the voice..."
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" h-24 w-full rounded px-3 py-2 text-sm outline-none
|
||||
/>
|
||||
<div flex="~ col gap-6">
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Language
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select voice language
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedLanguage"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="language in speechStore.availableLanguages" :key="language" :value="language">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Voice
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select preferred voice
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedVoice" border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="voice in availableVoices" :key="voice.id" :value="voice.name">
|
||||
{{ voice.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div flex="~ row" gap-4>
|
||||
<button
|
||||
border="zinc-300 dark:zinc-800 solid 1" transition="border duration-250 ease-in-out" rounded px-4
|
||||
py-2 text-sm :disabled="isGenerating || !testText.trim() || !apiKey"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || !testText.trim() || !apiKey }"
|
||||
@click="generateTestSpeech"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<div i-solar:play-circle-bold-duotone />
|
||||
<span>{{ isGenerating ? 'Generating...' : 'Test Voice' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="audioUrl" border="zinc-300 dark:zinc-800 solid 1" transition="border duration-250 ease-in-out"
|
||||
rounded px-4 py-2 text-sm @click="stopTestAudio"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<div i-solar:stop-circle-bold-duotone />
|
||||
<span>Stop</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!apiKey" class="mt-2 text-sm text-red-500">
|
||||
Please enter an API key to test the voice.
|
||||
</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>
|
||||
</Collapsable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div fixed bottom-0 right-0 text="neutral-100/80 dark:neutral-500/20">
|
||||
|
||||
@@ -25,13 +25,13 @@ onMounted(() => {
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[providerId]?.apiKey || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.baseUrlDefault || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || ''
|
||||
})
|
||||
|
||||
watch([apiKey, baseUrl], () => {
|
||||
providers.value[providerId] = {
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.baseUrlDefault || '',
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl || '',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -115,7 +115,7 @@ watch([apiKey, baseUrl], () => {
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.baseUrlDefault"
|
||||
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Voice } from '@proj-airi/stage-ui/constants'
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
|
||||
|
||||
import { Collapsable } from '@proj-airi/stage-ui/components'
|
||||
import { voiceMap } from '@proj-airi/stage-ui/constants'
|
||||
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { generateSpeech } from '@xsai/generate-speech'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -11,6 +16,13 @@ const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
// For playground
|
||||
const testText = ref('Hello! This is a test of the ElevenLabs voice synthesis.')
|
||||
const isGenerating = ref(false)
|
||||
const audioUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'elevenlabs'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
@@ -18,6 +30,13 @@ const providerMetadata = computed(() => providersStore.getProviderMetadata(provi
|
||||
const apiKey = ref(providers.value[providerId]?.apiKey || '')
|
||||
const baseUrl = ref(providers.value[providerId]?.baseUrl || '')
|
||||
|
||||
// Voice settings as individual refs
|
||||
const similarityBoost = ref((providers.value[providerId]?.voiceSettings as any)?.similarityBoost || 0.75)
|
||||
const stability = ref((providers.value[providerId]?.voiceSettings as any)?.stability || 0.5)
|
||||
const speed = ref((providers.value[providerId]?.voiceSettings as any)?.speed || 1.0)
|
||||
const style = ref((providers.value[providerId]?.voiceSettings as any)?.style || 0)
|
||||
const useSpeakerBoost = ref((providers.value[providerId]?.voiceSettings as any)?.useSpeakerBoost !== false)
|
||||
|
||||
// Speech settings
|
||||
const selectedLanguage = ref(speechStore.selectedLanguage)
|
||||
const selectedVoice = ref(speechStore.voiceName)
|
||||
@@ -31,7 +50,16 @@ onMounted(() => {
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[providerId]?.apiKey || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.baseUrlDefault || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || ''
|
||||
|
||||
// Initialize voice settings refs
|
||||
if (providers.value[providerId]?.voiceSettings) {
|
||||
similarityBoost.value = (providers.value[providerId].voiceSettings as any)?.similarityBoost ?? 0.75
|
||||
stability.value = (providers.value[providerId].voiceSettings as any)?.stability ?? 0.5
|
||||
speed.value = (providers.value[providerId].voiceSettings as any)?.speed ?? 1.0
|
||||
style.value = (providers.value[providerId].voiceSettings as any)?.style ?? 0
|
||||
useSpeakerBoost.value = (providers.value[providerId].voiceSettings as any)?.useSpeakerBoost !== false
|
||||
}
|
||||
|
||||
// Load voices if provider is configured
|
||||
if (providersStore.configuredProviders[providerId]) {
|
||||
@@ -39,10 +67,19 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([apiKey, baseUrl], () => {
|
||||
// Watch all settings and update the provider configuration
|
||||
watch([apiKey, baseUrl, similarityBoost, stability, speed, style, useSpeakerBoost], () => {
|
||||
providers.value[providerId] = {
|
||||
...providers.value[providerId],
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.baseUrlDefault || '',
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl || '',
|
||||
voiceSettings: {
|
||||
similarityBoost: similarityBoost.value,
|
||||
stability: stability.value,
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,6 +90,80 @@ watch(selectedLanguage, (newLanguage) => {
|
||||
watch(selectedVoice, (newVoice) => {
|
||||
speechStore.setVoiceName(newVoice)
|
||||
})
|
||||
|
||||
// Function to generate speech
|
||||
async function generateTestSpeech() {
|
||||
if (!testText.value.trim())
|
||||
return
|
||||
|
||||
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, any>
|
||||
if (!provider) {
|
||||
console.error('Failed to initialize speech provider')
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
}
|
||||
|
||||
const response = await generateSpeech({
|
||||
...provider.speech('eleven_multilingual_v2', {
|
||||
voiceSettings: {
|
||||
stability: stability.value,
|
||||
similarityBoost: similarityBoost.value,
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}),
|
||||
input: testText.value,
|
||||
voice: voiceMap[selectedVoice.value as Voice],
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,201 +180,250 @@ watch(selectedVoice, (newVoice) => {
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-6">
|
||||
<div>
|
||||
<div flex="~ col gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Basic
|
||||
</h2>
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Essential settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
API Key
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400" text-nowrap>
|
||||
API Key for {{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKey" type="password"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="..."
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ col gap-6">
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Voice Settings
|
||||
</h2>
|
||||
<div flex="~ col gap-6">
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div flex="~ col md:row gap-6">
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-6" w="full md:40%">
|
||||
<div>
|
||||
<div flex="~ col gap-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Language
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select voice language
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedLanguage"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="language in speechStore.availableLanguages" :key="language" :value="language">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Voice
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select preferred voice
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedVoice"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="voice in availableVoices" :key="voice.id" :value="voice.name">
|
||||
{{ voice.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Pitch
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Adjust voice pitch
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input
|
||||
v-model="speechStore.pitch"
|
||||
type="range"
|
||||
min="-100"
|
||||
max="100"
|
||||
step="1"
|
||||
w-full
|
||||
>
|
||||
<span class="text-xs">{{ speechStore.pitch }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Rate
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Adjust speaking rate
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input
|
||||
v-model="speechStore.rate"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
w-full
|
||||
>
|
||||
<span class="text-xs">{{ speechStore.rate.toFixed(1) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
SSML
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Enable SSML support
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
v-model="speechStore.ssmlEnabled"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
:disabled="!speechStore.supportsSSML"
|
||||
>
|
||||
<span class="text-sm">{{ speechStore.ssmlEnabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Collapsable w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="() => slotProps.setVisible(!slotProps.visible) && toggleAdvancedVisible()"
|
||||
>
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
<span>Advanced</span>
|
||||
Basic
|
||||
</h2>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
<div text="neutral-400 dark:neutral-500">
|
||||
<span>Essential settings</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div mt-4>
|
||||
</div>
|
||||
<div max-w-full>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
API Key
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400" text-nowrap>
|
||||
API Key for {{ providerMetadata?.localizedName }}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKey" type="password"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
placeholder="..."
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ col gap-6">
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Voice Settings
|
||||
</h2>
|
||||
<div flex="~ col gap-6">
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Base URL
|
||||
Similarity Boost
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Custom base URL (optional)
|
||||
Voice similarity adherence
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="baseUrl" type="text"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.baseUrlDefault"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="similarityBoost" type="range" min="0" max="1" step="0.01" w-full>
|
||||
<span class="text-xs">{{ similarityBoost.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div mt-4>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Stability
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Voice stability and randomness
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="stability" type="range" min="0" max="1" step="0.01" w-full>
|
||||
<span class="text-xs">{{ stability.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Speed
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Speech generation speed
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="speed" type="range" min="0.7" max="1.2" step="0.01" w-full>
|
||||
<span class="text-xs">{{ speed.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Style
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Voice style exaggeration
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="style" type="range" min="0" max="1" step="0.01" w-full>
|
||||
<span class="text-xs">{{ style.toFixed(2) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Speaker Boost
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Enhance speaker similarity
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<input v-model="useSpeakerBoost" type="checkbox">
|
||||
<span class="text-xs">{{ useSpeakerBoost ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Collapsable w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
border="zinc-300 dark:zinc-800 solid 1"
|
||||
transition="border duration-250 ease-in-out"
|
||||
rounded
|
||||
px-4 py-2 text-sm @click="speechStore.resetVoiceSettings"
|
||||
transition="all ease-in-out duration-250" w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
@click="() => slotProps.setVisible(!slotProps.visible) && toggleAdvancedVisible()"
|
||||
>
|
||||
Reset Voice Settings
|
||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
<span>Advanced</span>
|
||||
</h2>
|
||||
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
|
||||
<div i-solar:alt-arrow-down-bold-duotone />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div mt-4>
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Base URL
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Custom base URL (optional)
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="baseUrl" type="text"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div mt-4>
|
||||
<button
|
||||
border="zinc-300 dark:zinc-800 solid 1" transition="border duration-250 ease-in-out" rounded px-4
|
||||
py-2 text-sm @click="speechStore.resetVoiceSettings"
|
||||
>
|
||||
Reset Voice Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ col gap-6" w="full md:60%">
|
||||
<div rounded-xl>
|
||||
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||
Voice Playground
|
||||
</h2>
|
||||
<div flex="~ col gap-4">
|
||||
<textarea
|
||||
v-model="testText" placeholder="Enter text to test the voice..."
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" h-24 w-full rounded px-3 py-2 text-sm outline-none
|
||||
/>
|
||||
<div flex="~ col gap-6">
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Language
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select voice language
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedLanguage"
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="language in speechStore.availableLanguages" :key="language" :value="language">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label grid="~ cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
Voice
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Select preferred voice
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedVoice" border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out" w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
>
|
||||
<option v-for="voice in availableVoices" :key="voice.id" :value="voice.name">
|
||||
{{ voice.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div flex="~ row" gap-4>
|
||||
<button
|
||||
border="zinc-300 dark:zinc-800 solid 1" transition="border duration-250 ease-in-out" rounded px-4
|
||||
py-2 text-sm :disabled="isGenerating || !testText.trim() || !apiKey"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || !testText.trim() || !apiKey }"
|
||||
@click="generateTestSpeech"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<div i-solar:play-circle-bold-duotone />
|
||||
<span>{{ isGenerating ? 'Generating...' : 'Test Voice' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="audioUrl" border="zinc-300 dark:zinc-800 solid 1" transition="border duration-250 ease-in-out"
|
||||
rounded px-4 py-2 text-sm @click="stopTestAudio"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<div i-solar:stop-circle-bold-duotone />
|
||||
<span>Stop</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!apiKey" class="mt-2 text-sm text-red-500">
|
||||
Please enter an API key to test the voice.
|
||||
</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>
|
||||
</Collapsable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div fixed bottom-0 right-0 text="neutral-100/80 dark:neutral-500/20">
|
||||
|
||||
@@ -25,13 +25,13 @@ onMounted(() => {
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[providerId]?.apiKey || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.baseUrlDefault || ''
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || ''
|
||||
})
|
||||
|
||||
watch([apiKey, baseUrl], () => {
|
||||
providers.value[providerId] = {
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.baseUrlDefault || '',
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl || '',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -115,7 +115,7 @@ watch([apiKey, baseUrl], () => {
|
||||
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
|
||||
transition="border duration-250 ease-in-out"
|
||||
w-full rounded px-2 py-1 text-nowrap text-sm outline-none
|
||||
:placeholder="providerMetadata?.baseUrlDefault"
|
||||
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useProvidersStore } from '../providers'
|
||||
|
||||
export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
const providersStore = useProvidersStore()
|
||||
const { providerMetadata } = storeToRefs(providersStore)
|
||||
|
||||
// State
|
||||
const activeProvider = useLocalStorage('settings/consciousness/active-provider', '')
|
||||
@@ -16,7 +17,7 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
|
||||
// Computed properties
|
||||
const supportsModelListing = computed(() => {
|
||||
return providersStore.supportsModelListing(activeProvider.value)
|
||||
return providerMetadata.value[activeProvider.value]?.capabilities.listModels !== undefined
|
||||
})
|
||||
|
||||
const providerModels = computed(() => {
|
||||
@@ -69,7 +70,7 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
}
|
||||
|
||||
async function loadModelsForProvider(provider: string) {
|
||||
if (provider && providersStore.supportsModelListing(provider)
|
||||
if (provider && providerMetadata.value[activeProvider.value]?.capabilities.listModels !== undefined
|
||||
&& providersStore.getModelsForProvider(provider).length === 0) {
|
||||
await providersStore.fetchModelsForProvider(provider)
|
||||
}
|
||||
|
||||
@@ -86,14 +86,6 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
return voiceMap[voiceName.value as Voice]
|
||||
})
|
||||
|
||||
function setPitch(value: number) {
|
||||
pitch.value = value
|
||||
}
|
||||
|
||||
function setRate(value: number) {
|
||||
rate.value = value
|
||||
}
|
||||
|
||||
function setSSMLEnabled(enabled: boolean) {
|
||||
ssmlEnabled.value = enabled
|
||||
}
|
||||
@@ -268,8 +260,6 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
setActiveSpeechProvider,
|
||||
setActiveSpeechModel,
|
||||
setVoiceName,
|
||||
setPitch,
|
||||
setRate,
|
||||
setSSMLEnabled,
|
||||
setLanguage,
|
||||
resetVoiceSettings,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import type { ChatProvider, EmbedProvider, SpeechProviderWithExtraOptions, TranscriptionProvider } from '@xsai-ext/shared-providers'
|
||||
import type {
|
||||
ChatProvider,
|
||||
ChatProviderWithExtraOptions,
|
||||
EmbedProvider,
|
||||
EmbedProviderWithExtraOptions,
|
||||
SpeechProvider,
|
||||
SpeechProviderWithExtraOptions,
|
||||
TranscriptionProvider,
|
||||
TranscriptionProviderWithExtraOptions,
|
||||
} from '@xsai-ext/shared-providers'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
@@ -30,11 +38,20 @@ export interface ProviderMetadata {
|
||||
icon?: string
|
||||
iconColor?: string
|
||||
iconImage?: string
|
||||
baseUrlDefault?: string
|
||||
createProvider: (config: Record<string, unknown>) => ChatProvider | EmbedProvider | SpeechProviderWithExtraOptions | TranscriptionProvider
|
||||
modelSelectionType: 'dynamic' | 'manual' | 'hardcoded'
|
||||
fetchModelsManually?: (config: Record<string, unknown>) => Promise<ModelInfo[]>
|
||||
hardcodedModels?: ModelInfo[]
|
||||
defaultOptions?: Record<string, unknown>
|
||||
createProvider: (config: Record<string, unknown>) =>
|
||||
| ChatProvider
|
||||
| ChatProviderWithExtraOptions
|
||||
| EmbedProvider
|
||||
| EmbedProviderWithExtraOptions
|
||||
| SpeechProvider
|
||||
| SpeechProviderWithExtraOptions
|
||||
| TranscriptionProvider
|
||||
| TranscriptionProviderWithExtraOptions
|
||||
capabilities: {
|
||||
listModels?: (config: Record<string, unknown>) => Promise<ModelInfo[]>
|
||||
listVoices?: (config: Record<string, unknown>) => Promise<VoiceInfo[]>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
@@ -47,6 +64,16 @@ export interface ModelInfo {
|
||||
deprecated?: boolean
|
||||
}
|
||||
|
||||
export interface VoiceInfo {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
description?: string
|
||||
gender?: string
|
||||
language?: string
|
||||
deprecated?: boolean
|
||||
}
|
||||
|
||||
export const useProvidersStore = defineStore('providers', () => {
|
||||
const providerCredentials = useLocalStorage<Record<string, Record<string, unknown>>>('settings/credentials/providers', {})
|
||||
|
||||
@@ -89,10 +116,15 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
descriptionKey: 'providers.openrouter.description',
|
||||
description: 'openrouter.ai',
|
||||
icon: 'i-lobe-icons:openrouter',
|
||||
baseUrlDefault: 'https://openrouter.ai/api/v1/',
|
||||
defaultOptions: {
|
||||
baseUrl: 'https://openrouter.ai/api/v1/',
|
||||
},
|
||||
createProvider: config => createOpenRouter(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'manual',
|
||||
fetchModelsManually: fetchOpenRouterModels,
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return fetchOpenRouterModels(config)
|
||||
},
|
||||
},
|
||||
},
|
||||
'openai': {
|
||||
id: 'openai',
|
||||
@@ -101,9 +133,26 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
descriptionKey: 'providers.openai.description',
|
||||
description: 'openai.com',
|
||||
icon: 'i-lobe-icons:openai',
|
||||
baseUrlDefault: 'https://api.openai.com/v1/',
|
||||
defaultOptions: {
|
||||
baseUrl: 'https://api.openai.com/v1/',
|
||||
},
|
||||
createProvider: config => createOpenAI(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createOpenAI(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'openai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'ollama-ai': {
|
||||
id: 'ollama-ai',
|
||||
@@ -112,9 +161,26 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
descriptionKey: 'providers.ollama.description',
|
||||
description: 'ollama.com',
|
||||
icon: 'i-lobe-icons:ollama',
|
||||
baseUrlDefault: 'http://localhost:11434/api/',
|
||||
defaultOptions: {
|
||||
baseUrl: 'http://localhost:11434/api/',
|
||||
},
|
||||
createProvider: config => createOllama(config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createOllama(config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'ollama-ai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'vllm': {
|
||||
id: 'vllm',
|
||||
@@ -124,51 +190,54 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'vllm.ai',
|
||||
iconColor: 'i-lobe-icons:vllm-color',
|
||||
createProvider: config => createOllama(config.baseUrl as string),
|
||||
modelSelectionType: 'hardcoded',
|
||||
hardcodedModels: [
|
||||
{
|
||||
id: 'llama-2-7b',
|
||||
name: 'Llama 2 (7B)',
|
||||
provider: 'vllm',
|
||||
description: 'Meta\'s Llama 2 7B parameter model',
|
||||
contextLength: 4096,
|
||||
capabilities: {
|
||||
listModels: async () => {
|
||||
return [
|
||||
{
|
||||
id: 'llama-2-7b',
|
||||
name: 'Llama 2 (7B)',
|
||||
provider: 'vllm',
|
||||
description: 'Meta\'s Llama 2 7B parameter model',
|
||||
contextLength: 4096,
|
||||
},
|
||||
{
|
||||
id: 'llama-2-13b',
|
||||
name: 'Llama 2 (13B)',
|
||||
provider: 'vllm',
|
||||
description: 'Meta\'s Llama 2 13B parameter model',
|
||||
contextLength: 4096,
|
||||
},
|
||||
{
|
||||
id: 'llama-2-70b',
|
||||
name: 'Llama 2 (70B)',
|
||||
provider: 'vllm',
|
||||
description: 'Meta\'s Llama 2 70B parameter model',
|
||||
contextLength: 4096,
|
||||
},
|
||||
{
|
||||
id: 'mistral-7b',
|
||||
name: 'Mistral (7B)',
|
||||
provider: 'vllm',
|
||||
description: 'Mistral AI\'s 7B parameter model',
|
||||
contextLength: 8192,
|
||||
},
|
||||
{
|
||||
id: 'mixtral-8x7b',
|
||||
name: 'Mixtral (8x7B)',
|
||||
provider: 'vllm',
|
||||
description: 'Mistral AI\'s Mixtral 8x7B MoE model',
|
||||
contextLength: 32768,
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom Model',
|
||||
provider: 'vllm',
|
||||
description: 'Specify a custom model name',
|
||||
contextLength: 0,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'llama-2-13b',
|
||||
name: 'Llama 2 (13B)',
|
||||
provider: 'vllm',
|
||||
description: 'Meta\'s Llama 2 13B parameter model',
|
||||
contextLength: 4096,
|
||||
},
|
||||
{
|
||||
id: 'llama-2-70b',
|
||||
name: 'Llama 2 (70B)',
|
||||
provider: 'vllm',
|
||||
description: 'Meta\'s Llama 2 70B parameter model',
|
||||
contextLength: 4096,
|
||||
},
|
||||
{
|
||||
id: 'mistral-7b',
|
||||
name: 'Mistral (7B)',
|
||||
provider: 'vllm',
|
||||
description: 'Mistral AI\'s 7B parameter model',
|
||||
contextLength: 8192,
|
||||
},
|
||||
{
|
||||
id: 'mixtral-8x7b',
|
||||
name: 'Mixtral (8x7B)',
|
||||
provider: 'vllm',
|
||||
description: 'Mistral AI\'s Mixtral 8x7B MoE model',
|
||||
contextLength: 32768,
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom Model',
|
||||
provider: 'vllm',
|
||||
description: 'Specify a custom model name',
|
||||
contextLength: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'perplexity-ai': {
|
||||
id: 'perplexity-ai',
|
||||
@@ -177,46 +246,51 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
descriptionKey: 'providers.perplexity.description',
|
||||
description: 'perplexity.ai',
|
||||
icon: 'i-lobe-icons:perplexity',
|
||||
baseUrlDefault: 'https://api.perplexity.ai',
|
||||
defaultOptions: {
|
||||
baseUrl: 'https://api.perplexity.ai',
|
||||
},
|
||||
createProvider: config => createPerplexity(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'hardcoded',
|
||||
hardcodedModels: [
|
||||
{
|
||||
id: 'sonar-small-online',
|
||||
name: 'Sonar Small (Online)',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Efficient model with online search capabilities',
|
||||
contextLength: 12000,
|
||||
capabilities: {
|
||||
listModels: async () => {
|
||||
return [
|
||||
{
|
||||
id: 'sonar-small-online',
|
||||
name: 'Sonar Small (Online)',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Efficient model with online search capabilities',
|
||||
contextLength: 12000,
|
||||
},
|
||||
{
|
||||
id: 'sonar-medium-online',
|
||||
name: 'Sonar Medium (Online)',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Balanced model with online search capabilities',
|
||||
contextLength: 12000,
|
||||
},
|
||||
{
|
||||
id: 'sonar-large-online',
|
||||
name: 'Sonar Large (Online)',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Powerful model with online search capabilities',
|
||||
contextLength: 12000,
|
||||
},
|
||||
{
|
||||
id: 'codey-small',
|
||||
name: 'Codey Small',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Specialized for code generation and understanding',
|
||||
contextLength: 12000,
|
||||
},
|
||||
{
|
||||
id: 'codey-large',
|
||||
name: 'Codey Large',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Advanced code generation and understanding',
|
||||
contextLength: 12000,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sonar-medium-online',
|
||||
name: 'Sonar Medium (Online)',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Balanced model with online search capabilities',
|
||||
contextLength: 12000,
|
||||
},
|
||||
{
|
||||
id: 'sonar-large-online',
|
||||
name: 'Sonar Large (Online)',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Powerful model with online search capabilities',
|
||||
contextLength: 12000,
|
||||
},
|
||||
{
|
||||
id: 'codey-small',
|
||||
name: 'Codey Small',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Specialized for code generation and understanding',
|
||||
contextLength: 12000,
|
||||
},
|
||||
{
|
||||
id: 'codey-large',
|
||||
name: 'Codey Large',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Advanced code generation and understanding',
|
||||
contextLength: 12000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'elevenlabs': {
|
||||
id: 'elevenlabs',
|
||||
@@ -225,19 +299,19 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
descriptionKey: 'providers.elevenlabs.description',
|
||||
description: 'elevenlabs.io',
|
||||
icon: 'i-simple-icons:elevenlabs',
|
||||
baseUrlDefault: 'https://unspeech.hyp3r.link/v1/',
|
||||
defaultOptions: {
|
||||
baseUrl: 'https://unspeech.hyp3r.link/v1/',
|
||||
},
|
||||
// TODO: UnElevenLabsOptions
|
||||
createProvider: config => createUnElevenLabs(config.apiKey as string, config.baseUrl as string) as SpeechProviderWithExtraOptions<string, any>,
|
||||
modelSelectionType: 'hardcoded',
|
||||
hardcodedModels: [
|
||||
{
|
||||
id: 'sonar-small-online',
|
||||
name: 'Sonar Small (Online)',
|
||||
provider: 'perplexity-ai',
|
||||
description: 'Efficient model with online search capabilities',
|
||||
contextLength: 12000,
|
||||
capabilities: {
|
||||
listModels: async () => {
|
||||
return []
|
||||
},
|
||||
],
|
||||
listVoices: async () => {
|
||||
return []
|
||||
},
|
||||
},
|
||||
},
|
||||
'xai': {
|
||||
id: 'xai',
|
||||
@@ -247,7 +321,22 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'x.ai',
|
||||
icon: 'i-lobe-icons:xai',
|
||||
createProvider: config => createXAI(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createXAI(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'xai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'deepseek': {
|
||||
id: 'deepseek',
|
||||
@@ -257,7 +346,22 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'deepseek.com',
|
||||
iconColor: 'i-lobe-icons:deepseek-color',
|
||||
createProvider: config => createDeepSeek(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createDeepSeek(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'deepseek',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'together-ai': {
|
||||
id: 'together-ai',
|
||||
@@ -267,7 +371,22 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'together.ai',
|
||||
iconColor: 'i-lobe-icons:together-color',
|
||||
createProvider: config => createTogetherAI(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createTogetherAI(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'together-ai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'novita-ai': {
|
||||
id: 'novita-ai',
|
||||
@@ -277,7 +396,22 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'novita.ai',
|
||||
iconColor: 'i-lobe-icons:novita-color',
|
||||
createProvider: config => createNovita(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createNovita(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'novita-ai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'fireworks-ai': {
|
||||
id: 'fireworks-ai',
|
||||
@@ -287,7 +421,22 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'fireworks.ai',
|
||||
icon: 'i-lobe-icons:fireworks',
|
||||
createProvider: config => createFireworks(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createFireworks(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'fireworks-ai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'cloudflare-workers-ai': {
|
||||
id: 'cloudflare-workers-ai',
|
||||
@@ -297,7 +446,11 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'cloudflare.com',
|
||||
iconColor: 'i-lobe-icons:cloudflare-color',
|
||||
createProvider: config => createWorkersAI(config.apiKey as string, config.accountId as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async () => {
|
||||
return []
|
||||
},
|
||||
},
|
||||
},
|
||||
'mistral-ai': {
|
||||
id: 'mistral-ai',
|
||||
@@ -307,7 +460,22 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'mistral.ai',
|
||||
iconColor: 'i-lobe-icons:mistral-color',
|
||||
createProvider: config => createMistral(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createMistral(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'mistral-ai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
'moonshot-ai': {
|
||||
id: 'moonshot-ai',
|
||||
@@ -317,7 +485,22 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
description: 'moonshot.ai',
|
||||
icon: 'i-lobe-icons:moonshot',
|
||||
createProvider: config => createMoonshot(config.apiKey as string, config.baseUrl as string),
|
||||
modelSelectionType: 'dynamic',
|
||||
capabilities: {
|
||||
listModels: async (config) => {
|
||||
return (await listModels({
|
||||
...createMoonshot(config.apiKey as string, config.baseUrl as string).model(),
|
||||
})).map((model) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'moonshot-ai',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
} satisfies ModelInfo
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -367,7 +550,7 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
if (!providerCredentials.value[providerId]) {
|
||||
const metadata = providerMetadata[providerId]
|
||||
providerCredentials.value[providerId] = {
|
||||
baseUrl: metadata.baseUrlDefault || '',
|
||||
baseUrl: metadata.defaultOptions?.baseUrl || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,17 +581,6 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
const isLoadingModels = ref<Record<string, boolean>>({})
|
||||
const modelLoadError = ref<Record<string, string | null>>({})
|
||||
|
||||
// Check if a provider supports model listing (any type)
|
||||
function supportsModelListing(providerId: string): boolean {
|
||||
const metadata = providerMetadata[providerId]
|
||||
if (!metadata)
|
||||
return false
|
||||
|
||||
return metadata.modelSelectionType === 'dynamic'
|
||||
|| metadata.modelSelectionType === 'manual'
|
||||
|| (metadata.modelSelectionType === 'hardcoded' && !!metadata.hardcodedModels?.length)
|
||||
}
|
||||
|
||||
// Function to fetch models for a specific provider
|
||||
async function fetchModelsForProvider(providerId: string) {
|
||||
const config = providerCredentials.value[providerId]
|
||||
@@ -423,49 +595,14 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
modelLoadError.value[providerId] = null
|
||||
|
||||
try {
|
||||
// Handle different model selection types
|
||||
switch (metadata.modelSelectionType) {
|
||||
case 'dynamic':
|
||||
if (!metadata.createProvider) {
|
||||
throw new Error(`Provider ${providerId} has no createProvider function`)
|
||||
}
|
||||
const models = metadata.capabilities.listModels ? await metadata.capabilities.listModels(config) : []
|
||||
|
||||
const providerInstance = metadata.createProvider(config)
|
||||
|
||||
// Check if provider supports model listing
|
||||
if (!('model' in providerInstance && typeof providerInstance.model === 'function')) {
|
||||
throw new Error(`Provider ${providerId} does not support model listing`)
|
||||
}
|
||||
|
||||
// Get models using the provider's model() function
|
||||
const models = await listModels(providerInstance.model())
|
||||
|
||||
// Transform and store the models
|
||||
availableModels.value[providerId] = models.map(model => ({
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: providerId,
|
||||
}))
|
||||
break
|
||||
|
||||
case 'manual':
|
||||
if (!metadata.fetchModelsManually) {
|
||||
throw new Error(`Provider ${providerId} has no fetchModelsManually function`)
|
||||
}
|
||||
|
||||
// Use custom fetch function
|
||||
availableModels.value[providerId] = await metadata.fetchModelsManually(config)
|
||||
break
|
||||
|
||||
case 'hardcoded':
|
||||
if (!metadata.hardcodedModels) {
|
||||
throw new Error(`Provider ${providerId} has no hardcodedModels defined`)
|
||||
}
|
||||
|
||||
// Use hardcoded models
|
||||
availableModels.value[providerId] = metadata.hardcodedModels
|
||||
break
|
||||
}
|
||||
// Transform and store the models
|
||||
availableModels.value[providerId] = models.map(model => ({
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: providerId,
|
||||
}))
|
||||
|
||||
return availableModels.value[providerId]
|
||||
}
|
||||
@@ -496,7 +633,7 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
// Load models for all configured providers
|
||||
async function loadModelsForConfiguredProviders() {
|
||||
for (const providerId of availableProviders.value) {
|
||||
if (supportsModelListing(providerId)) {
|
||||
if (providerMetadata[providerId].capabilities.listModels) {
|
||||
await fetchModelsForProvider(providerId)
|
||||
}
|
||||
}
|
||||
@@ -568,7 +705,6 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
getModelsForProvider,
|
||||
allAvailableModels,
|
||||
loadModelsForConfiguredProviders,
|
||||
supportsModelListing,
|
||||
getProviderInstance,
|
||||
availableProvidersMetadata,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user