Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/src/components/sidebar/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ const sidebarInfo = computed(() => [
name: 'memory-providers',
icon: ['fas', 'brain'],
},
{
title: t('sidebar.ttsProvider'),
name: 'tts-providers',
icon: ['fas', 'volume-high'],
},
{
title: t('sidebar.emailProvider'),
name: 'email-providers',
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"models": "Models",
"searchProvider": "Search Providers",
"memoryProvider": "Memory",
"ttsProvider": "TTS Providers",
"emailProvider": "Email Providers",
"settings": "Settings",
"home": "Home",
Expand Down Expand Up @@ -256,6 +257,39 @@
"builtin": "Built-in"
}
},
"ttsProvider": {
"title": "TTS Providers",
"add": "Add TTS Provider",
"providerType": "Provider Type",
"searchPlaceholder": "Search TTS providers...",
"emptyTitle": "No TTS Providers",
"emptyDescription": "Add a TTS provider to enable text-to-speech for your bots",
"deleteConfirm": "Are you sure you want to delete this TTS provider? This action cannot be undone.",
"models": "Models",
"importModels": "Import Models",
"importSuccess": "Models imported successfully",
"importFailed": "Failed to import models",
"noModels": "No models found. Click \"Import Models\" to discover available models.",
"noCapabilities": "No capabilities available for this model.",
"fields": {
"language": "Language",
"languagePlaceholder": "Select language...",
"voice": "Voice",
"voicePlaceholder": "Select voice...",
"format": "Output Format",
"formatPlaceholder": "Select format...",
"speed": "Speed",
"speedDescription": "Playback speed (default: {default})",
"pitch": "Pitch",
"pitchDescription": "Voice pitch adjustment in Hz (default: {default})"
},
"test": {
"title": "Test Synthesis",
"placeholder": "Enter text to synthesize...",
"generate": "Generate",
"failed": "Synthesis failed"
}
},
"emailProvider": {
"title": "Email Providers",
"add": "Add Email Provider",
Expand Down Expand Up @@ -611,6 +645,8 @@
"searchProviderPlaceholder": "Select search provider",
"memoryProvider": "Memory Provider",
"memoryProviderPlaceholder": "Select memory provider (disabled if empty)",
"ttsModel": "TTS Model",
"ttsModelPlaceholder": "Select TTS model",
"maxContextLoadTime": "Max Context Load Time",
"maxContextTokens": "Max Context Tokens",
"language": "Language",
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"models": "模型管理",
"searchProvider": "搜索提供方",
"memoryProvider": "记忆",
"ttsProvider": "语音合成",
"emailProvider": "邮件提供方",
"settings": "设置",
"home": "首页",
Expand Down Expand Up @@ -252,6 +253,39 @@
"builtin": "内置"
}
},
"ttsProvider": {
"title": "语音合成",
"add": "添加语音合成提供方",
"providerType": "提供方类型",
"searchPlaceholder": "搜索语音合成...",
"emptyTitle": "暂无语音合成提供方",
"emptyDescription": "添加语音合成提供方以为 Bot 启用文字转语音功能",
"deleteConfirm": "确定要删除此语音合成提供方吗?此操作不可撤销。",
"models": "模型",
"importModels": "导入模型",
"importSuccess": "模型导入成功",
"importFailed": "模型导入失败",
"noModels": "暂无模型,点击\"导入模型\"以发现可用模型。",
"noCapabilities": "该模型暂无可用能力信息。",
"fields": {
"language": "语言",
"languagePlaceholder": "选择语言...",
"voice": "声音",
"voicePlaceholder": "选择声音...",
"format": "输出格式",
"formatPlaceholder": "选择格式...",
"speed": "语速",
"speedDescription": "播放速度(默认:{default})",
"pitch": "音调",
"pitchDescription": "语音音调调整,单位 Hz(默认:{default})"
},
"test": {
"title": "测试合成",
"placeholder": "输入要合成的文本...",
"generate": "生成",
"failed": "合成失败"
}
},
"emailProvider": {
"title": "邮件提供方",
"add": "添加邮件提供方",
Expand Down Expand Up @@ -607,6 +641,8 @@
"searchProviderPlaceholder": "选择搜索提供方",
"memoryProvider": "记忆提供方",
"memoryProviderPlaceholder": "选择记忆提供方(为空则禁用)",
"ttsModel": "语音合成模型",
"ttsModelPlaceholder": "选择语音合成模型",
"maxContextLoadTime": "最大上下文加载时间",
"maxContextTokens": "最大上下文Token数",
"language": "语言",
Expand Down
42 changes: 40 additions & 2 deletions apps/web/src/pages/bots/components/bot-settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@
/>
</div>

<!-- TTS Model -->
<div class="space-y-2">
<Label>{{ $t('bots.settings.ttsModel') }}</Label>
<TtsModelSelect
v-model="form.tts_model_id"
:models="ttsModels"
:providers="ttsProviders"
:placeholder="$t('bots.settings.ttsModelPlaceholder')"
/>
</div>

<!-- Browser Context -->
<div class="space-y-2">
<Label>{{ $t('bots.settings.browserContext') }}</Label>
Expand Down Expand Up @@ -199,9 +210,10 @@ import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ModelSelect from './model-select.vue'
import SearchProviderSelect from './search-provider-select.vue'
import MemoryProviderSelect from './memory-provider-select.vue'
import TtsModelSelect from './tts-model-select.vue'
import BrowserContextSelect from './browser-context-select.vue'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getBrowserContexts } from '@memoh/sdk'
import { getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getTtsProviders, getBrowserContexts } from '@memoh/sdk'
import type { SettingsSettings } from '@memoh/sdk'
import type { Ref } from 'vue'
import { resolveApiErrorMessage } from '@/utils/api-error'
Expand Down Expand Up @@ -262,6 +274,27 @@ const { data: memoryProviderData } = useQuery({
},
})

const { data: ttsProviderData } = useQuery({
key: ['tts-providers'],
query: async () => {
const { data } = await getTtsProviders({ throwOnError: true })
return data
},
})

const { data: ttsModelData } = useQuery({
key: ['tts-models'],
query: async () => {
const apiBase = import.meta.env.VITE_API_URL?.trim() || '/api'
const token = localStorage.getItem('token')
const resp = await fetch(`${apiBase}/tts-models`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!resp.ok) throw new Error('Failed to fetch TTS models')
return resp.json()
},
})

const { data: browserContextData } = useQuery({
key: ['all-browser-contexts'],
query: async () => {
Expand Down Expand Up @@ -296,6 +329,8 @@ const models = computed(() => modelData.value ?? [])
const providers = computed(() => providerData.value ?? [])
const searchProviders = computed(() => searchProviderData.value ?? [])
const memoryProviders = computed(() => memoryProviderData.value ?? [])
const ttsProviders = computed(() => ttsProviderData.value ?? [])
const ttsModels = computed(() => ttsModelData.value ?? [])
const browserContexts = computed(() => browserContextData.value ?? [])

const chatModelSupportsReasoning = computed(() => {
Expand All @@ -309,6 +344,7 @@ const form = reactive({
chat_model_id: '',
search_provider_id: '',
memory_provider_id: '',
tts_model_id: '',
browser_context_id: '',
max_context_load_time: 0,
max_context_tokens: 0,
Expand All @@ -323,6 +359,7 @@ watch(settings, (val) => {
form.chat_model_id = val.chat_model_id ?? ''
form.search_provider_id = val.search_provider_id ?? ''
form.memory_provider_id = (val as any).memory_provider_id ?? ''
form.tts_model_id = (val as any).tts_model_id ?? ''
form.browser_context_id = (val as any).browser_context_id ?? ''
form.max_context_load_time = val.max_context_load_time ?? 0
form.max_context_tokens = val.max_context_tokens ?? 0
Expand All @@ -340,6 +377,7 @@ const hasChanges = computed(() => {
form.chat_model_id !== (s.chat_model_id ?? '')
|| form.search_provider_id !== (s.search_provider_id ?? '')
|| form.memory_provider_id !== (s.memory_provider_id ?? '')
|| form.tts_model_id !== (s.tts_model_id ?? '')
|| form.browser_context_id !== (s.browser_context_id ?? '')
|| form.max_context_load_time !== (s.max_context_load_time ?? 0)
|| form.max_context_tokens !== (s.max_context_tokens ?? 0)
Expand All @@ -365,7 +403,7 @@ async function handleDeleteBot() {
try {
await deleteBot()
await router.push({ name: 'bots' })
toast.success(t('bots.deleteSuccess'))
toast.success(t('bots.deleteSuccess'))
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.lifecycle.deleteFailed')))
}
Expand Down
107 changes: 107 additions & 0 deletions apps/web/src/pages/bots/components/tts-model-select.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<template>
<SearchableSelectPopover
v-model="selected"
:options="options"
:placeholder="placeholder || ''"
:aria-label="placeholder || 'Select TTS model'"
:search-placeholder="$t('ttsProvider.searchPlaceholder')"
search-aria-label="Search TTS models"
:empty-text="$t('ttsProvider.emptyTitle')"
>
<template #trigger="{ open, displayLabel }">
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
:aria-label="placeholder || 'Select TTS model'"
class="w-full justify-between font-normal"
>
<span class="flex items-center gap-2 truncate">
<FontAwesomeIcon
v-if="selected"
:icon="['fas', 'volume-high']"
class="size-3.5 text-muted-foreground"
/>
<span class="truncate">{{ displayLabel || placeholder }}</span>
</span>
<FontAwesomeIcon
:icon="['fas', 'magnifying-glass']"
class="ml-2 size-3.5 shrink-0 text-muted-foreground"
/>
</Button>
</template>

<template #option-icon="{ option }">
<FontAwesomeIcon
v-if="option.value"
:icon="['fas', 'volume-high']"
class="size-3.5 text-muted-foreground"
/>
</template>

<template #option-label="{ option }">
<span
class="truncate"
:class="{ 'text-muted-foreground': !option.value }"
>
{{ option.label }}
</span>
</template>
</SearchableSelectPopover>
</template>

<script setup lang="ts">
import { Button } from '@memoh/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue'
import type { SearchableSelectOption } from '@/components/searchable-select-popover/index.vue'

export interface TtsModelOption {
id: string
model_id: string
name: string
tts_provider_id: string
provider_type?: string
}

export interface TtsProviderOption {
id: string
name: string
provider: string
}

const props = defineProps<{
models: TtsModelOption[]
providers: TtsProviderOption[]
placeholder?: string
}>()
const { t } = useI18n()

const selected = defineModel<string>({ default: '' })

const providerMap = computed(() => {
const map = new Map<string, string>()
for (const p of props.providers) {
map.set(p.id, p.name ?? p.id)
}
return map
})

const options = computed<SearchableSelectOption[]>(() => {
const noneOption: SearchableSelectOption = {
value: '',
label: t('common.none'),
keywords: [t('common.none')],
}
const modelOptions = props.models.map((model) => ({
value: model.id || '',
label: model.name || model.model_id || '',
description: model.model_id,
group: model.tts_provider_id,
groupLabel: providerMap.value.get(model.tts_provider_id) ?? model.tts_provider_id,
keywords: [model.name ?? '', model.model_id ?? '', model.provider_type ?? ''],
}))
return [noneOption, ...modelOptions]
})
</script>
Loading