feat: add voice playback settings with 4-provider support (#608)

Add WebSpeech, OpenAI TTS, Custom endpoint, and Edge TTS providers.

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
This commit is contained in:
memeflyfly
2026-05-10 20:08:38 +08:00
committed by GitHub
parent 838791a740
commit 15195f0795
18 changed files with 1237 additions and 20 deletions
@@ -16,6 +16,7 @@ import {
renderHighlightedCodeBlock,
} from "./highlight";
import { useGlobalSpeech } from "@/composables/useSpeech";
import { useVoiceSettings } from "@/composables/useVoiceSettings";
const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
@@ -79,6 +80,7 @@ const previewUrl = ref<string | null>(null);
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const speech = useGlobalSpeech();
const voiceSettings = useVoiceSettings();
// Copy entire bubble content
const copyableContent = computed(() => {
@@ -351,25 +353,90 @@ const renderedToolResult = computed(() => {
// 语音播放相关
const canPlaySpeech = computed(() => {
// 只有 assistant 消息可以播放,且浏览器支持 Web Speech API
return props.message.role === 'assistant' &&
speech.isSupported &&
copyableContent.value;
});
// 只有 assistant 消息可以播放
if (props.message.role !== 'assistant') return false
if (!copyableContent.value) return false
// OpenAI / Custom / Edge 不依赖浏览器 Web Speech API
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge') return true
return speech.isSupported
})
const isPlayingThisMessage = computed(() => {
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value;
});
// OpenAI / Custom / Edge 模式
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPlaying.value
}
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value
})
const isPausedThisMessage = computed(() => {
return speech.currentMessageId.value === props.message.id && speech.isPaused.value;
});
// OpenAI / Custom / Edge 模式
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPaused.value
}
return speech.currentMessageId.value === props.message.id && speech.isPaused.value
})
function handleSpeechToggle() {
if (!canPlaySpeech.value) {
return
}
const content = props.message.content || ''
// OpenAI TTS 模式
if (voiceSettings.provider.value === 'openai') {
const apiUrl = voiceSettings.openaiBaseUrl.value
if (!apiUrl) {
console.warn('[MessageItem] OpenAI TTS 地址为空')
return
}
speech.openaiToggle(props.message.id, content, {
baseUrl: voiceSettings.openaiBaseUrl.value,
apiKey: voiceSettings.openaiApiKey.value,
model: voiceSettings.openaiModel.value,
voice: voiceSettings.openaiVoice.value,
})
return
}
// 自定义端点模式(OpenAI 兼容,如 GPT-SoVITS
if (voiceSettings.provider.value === 'custom') {
const apiUrl = voiceSettings.customUrl.value
if (!apiUrl) {
console.warn('[MessageItem] 自定义 TTS 地址为空')
return
}
speech.openaiToggle(props.message.id, content, {
baseUrl: voiceSettings.customUrl.value,
apiKey: voiceSettings.customApiKey.value || undefined,
})
return
}
// Edge TTS 模式
if (voiceSettings.provider.value === 'edge') {
// URL 为空时使用内建后端代理
const apiUrl = voiceSettings.edgeUrl.value || '/api/tts/proxy'
speech.openaiToggle(props.message.id, content, {
baseUrl: apiUrl,
voice: voiceSettings.edgeVoice.value,
})
return
}
// Web Speech API 模式
if (voiceSettings.provider.value === 'webspeech') {
const text = speech.extractReadableText(content)
if (text) {
speech.stop(false)
speech.speakViaBrowser(props.message.id, text, {
voiceName: voiceSettings.webspeechVoice.value || undefined,
})
}
return
}
// 后备(无 provider 匹配时)
speech.toggle(props.message.id, content)
}
@@ -380,7 +447,37 @@ onMounted(() => {
autoPlayHandler = (e: Event) => {
const customEvent = e as CustomEvent<{ messageId: string; content: string }>
if (customEvent.detail.messageId === props.message.id && canPlaySpeech.value) {
speech.enqueue(props.message.id, customEvent.detail.content || props.message.content || '')
const content = customEvent.detail.content || props.message.content || ''
if (voiceSettings.provider.value === 'openai') {
const apiUrl = voiceSettings.openaiBaseUrl.value
if (apiUrl) speech.openaiPlay(props.message.id, content, {
baseUrl: voiceSettings.openaiBaseUrl.value,
apiKey: voiceSettings.openaiApiKey.value,
model: voiceSettings.openaiModel.value,
voice: voiceSettings.openaiVoice.value,
})
} else if (voiceSettings.provider.value === 'custom') {
const apiUrl = voiceSettings.customUrl.value
if (apiUrl) speech.openaiPlay(props.message.id, content, {
baseUrl: voiceSettings.customUrl.value,
apiKey: voiceSettings.customApiKey.value || undefined,
})
} else if (voiceSettings.provider.value === 'edge') {
speech.openaiPlay(props.message.id, content, {
baseUrl: '/api/tts/proxy',
voice: voiceSettings.edgeVoice.value,
})
} else if (voiceSettings.provider.value === 'webspeech') {
const text = speech.extractReadableText(content)
if (text) {
speech.stop(false)
speech.speakViaBrowser(props.message.id, text, {
voiceName: voiceSettings.webspeechVoice.value || undefined,
})
}
} else {
speech.enqueue(props.message.id, content)
}
}
}
window.addEventListener('auto-play-speech', autoPlayHandler)