diff --git a/apps/web/src/components/sidebar/index.vue b/apps/web/src/components/sidebar/index.vue index 175ce22f..f1e41e6d 100644 --- a/apps/web/src/components/sidebar/index.vue +++ b/apps/web/src/components/sidebar/index.vue @@ -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', diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index c5f7815b..1ca4579b 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -55,6 +55,7 @@ "models": "Models", "searchProvider": "Search Providers", "memoryProvider": "Memory", + "ttsProvider": "TTS Providers", "emailProvider": "Email Providers", "settings": "Settings", "home": "Home", @@ -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", @@ -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", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index e2ae97d4..b13d7d3a 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -55,6 +55,7 @@ "models": "模型管理", "searchProvider": "搜索提供方", "memoryProvider": "记忆", + "ttsProvider": "语音合成", "emailProvider": "邮件提供方", "settings": "设置", "home": "首页", @@ -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": "添加邮件提供方", @@ -607,6 +641,8 @@ "searchProviderPlaceholder": "选择搜索提供方", "memoryProvider": "记忆提供方", "memoryProviderPlaceholder": "选择记忆提供方(为空则禁用)", + "ttsModel": "语音合成模型", + "ttsModelPlaceholder": "选择语音合成模型", "maxContextLoadTime": "最大上下文加载时间", "maxContextTokens": "最大上下文Token数", "language": "语言", diff --git a/apps/web/src/pages/bots/components/bot-settings.vue b/apps/web/src/pages/bots/components/bot-settings.vue index 43341d91..8303047e 100644 --- a/apps/web/src/pages/bots/components/bot-settings.vue +++ b/apps/web/src/pages/bots/components/bot-settings.vue @@ -32,6 +32,17 @@ /> + +
+ + +
+
@@ -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' @@ -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 () => { @@ -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(() => { @@ -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, @@ -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 @@ -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) @@ -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'))) } diff --git a/apps/web/src/pages/bots/components/tts-model-select.vue b/apps/web/src/pages/bots/components/tts-model-select.vue new file mode 100644 index 00000000..9e8386ee --- /dev/null +++ b/apps/web/src/pages/bots/components/tts-model-select.vue @@ -0,0 +1,107 @@ + + + diff --git a/apps/web/src/pages/bots/components/tts-provider-select.vue b/apps/web/src/pages/bots/components/tts-provider-select.vue new file mode 100644 index 00000000..9fcfe9bd --- /dev/null +++ b/apps/web/src/pages/bots/components/tts-provider-select.vue @@ -0,0 +1,84 @@ + + + diff --git a/apps/web/src/pages/chat/components/attachment-block.vue b/apps/web/src/pages/chat/components/attachment-block.vue index 8b3338e8..171c27b7 100644 --- a/apps/web/src/pages/chat/components/attachment-block.vue +++ b/apps/web/src/pages/chat/components/attachment-block.vue @@ -28,6 +28,19 @@ /> + +
+
+ ): boolean { return mime.startsWith('video/') } +function isAudio(att: Record): boolean { + const type = String(att.type ?? '').toLowerCase() + if (type === 'audio' || type === 'voice') return true + const mime = String(att.mime ?? '').toLowerCase() + return mime.startsWith('audio/') +} + function handleMediaClick(att: Record) { const src = getUrl(att) if (src && props.onOpenMedia) { diff --git a/apps/web/src/pages/tts-providers/components/add-tts-provider.vue b/apps/web/src/pages/tts-providers/components/add-tts-provider.vue new file mode 100644 index 00000000..ec9742dc --- /dev/null +++ b/apps/web/src/pages/tts-providers/components/add-tts-provider.vue @@ -0,0 +1,141 @@ + + + diff --git a/apps/web/src/pages/tts-providers/components/model-config-editor.vue b/apps/web/src/pages/tts-providers/components/model-config-editor.vue new file mode 100644 index 00000000..bb05945b --- /dev/null +++ b/apps/web/src/pages/tts-providers/components/model-config-editor.vue @@ -0,0 +1,405 @@ +