feat(chat): add voice playback with auto-play and visual effects (#396)
## Features ### Core Functionality - **Web Speech API Integration**: Add TTS (text-to-speech) playback for assistant messages - **Manual Playback**: Click-to-play button next to each assistant message (🔊 icon) - **Auto-play Mode**: Toggle switch in input bar to auto-play responses - **Playback Controls**: Play/pause/stop with visual feedback ### User Interface - **Playback Button**: Hover-activated button in message meta area (next to copy button) - **Auto-play Switch**: Voice icon toggle in input top bar with state persistence - **Mobile Optimization**: Buttons always visible on mobile (≤768px width) - **Visual Feedback**: - Rainbow glowing border during playback (2px border, 10px/20px glow) - 4-second animation cycle through 6 colors - Play/pause icon toggle ### Voice Customization - **Pitch/Rate Control**: Low-pitched (0.5) fast-speaking (1.2) "male voice" - **Auto Voice Selection**: Attempts to select male voices across platforms (macOS: Yaoyao, Windows: David/Daniel) - **Platform Compatibility**: Works with system-provided voices on macOS, iOS, Android, Windows ### Content Filtering - Smart text extraction: filters code blocks, `<thinking>` tags, HTML - Only assistant messages are eligible for playback - Tool and system messages are excluded ### Internationalization - Added 8 language translations (en, zh, de, es, fr, ja, ko, pt) - New keys: `playSpeech`, `pauseSpeech`, `resumeSpeech`, `stopSpeech`, `autoPlaySpeech`, `speechNotSupported` ## Technical Details ### New Files - `packages/client/src/composables/useSpeech.ts`: Core speech synthesis composable - Voice loading and selection logic - Single-instance global speech manager - Event handling (onstart, onend, onerror, onboundary) - State management (isPlaying, isPaused, currentMessageId) ### Modified Components - **ChatInput.vue**: Auto-play switch with localStorage persistence - **MessageItem.vue**: - Playback button integration - Event listener for auto-play triggers - Mobile-first button visibility - Rainbow border animation during playback ### Store Changes - `chat.ts`: - Added `autoPlaySpeechEnabled` state - `setAutoPlaySpeech()` method - `playMessageSpeech()` method for event-based playback - Auto-play trigger on `run.completed` event ## Browser Support - Requires Web Speech API support (all modern browsers) - Graceful degradation: button hidden if API not supported - Voice availability varies by platform and OS Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { useChatStore } from '@/stores/hermes/chat'
|
|||||||
import { useAppStore } from '@/stores/hermes/app'
|
import { useAppStore } from '@/stores/hermes/app'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
import { fetchContextLength } from '@/api/hermes/sessions'
|
import { fetchContextLength } from '@/api/hermes/sessions'
|
||||||
import { NButton, NTooltip } from 'naive-ui'
|
import { NButton, NTooltip, NSwitch } from 'naive-ui'
|
||||||
import { computed, ref, onMounted, watch } from 'vue'
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -18,6 +18,26 @@ const isDragging = ref(false)
|
|||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const isComposing = ref(false)
|
const isComposing = ref(false)
|
||||||
|
|
||||||
|
// 自动播放语音开关
|
||||||
|
const autoPlaySpeech = ref(false)
|
||||||
|
|
||||||
|
// 从 localStorage 读取设置
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('autoPlaySpeech')
|
||||||
|
if (saved !== null) {
|
||||||
|
autoPlaySpeech.value = saved === 'true'
|
||||||
|
// 同步到 chat store
|
||||||
|
chatStore.setAutoPlaySpeech(autoPlaySpeech.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听变化并保存
|
||||||
|
watch(autoPlaySpeech, (value) => {
|
||||||
|
localStorage.setItem('autoPlaySpeech', String(value))
|
||||||
|
// 通知 chat store
|
||||||
|
chatStore.setAutoPlaySpeech(value)
|
||||||
|
})
|
||||||
|
|
||||||
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
||||||
|
|
||||||
// --- Context info ---
|
// --- Context info ---
|
||||||
@@ -195,7 +215,7 @@ function isImage(type: string): boolean {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
<!-- Top bar: attach + context info -->
|
<!-- Top bar: attach + auto play speech + context info -->
|
||||||
<div class="input-top-bar">
|
<div class="input-top-bar">
|
||||||
<NTooltip trigger="hover">
|
<NTooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -207,6 +227,25 @@ function isImage(type: string): boolean {
|
|||||||
</template>
|
</template>
|
||||||
{{ t('chat.attachFiles') }}
|
{{ t('chat.attachFiles') }}
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
|
|
||||||
|
<div class="auto-play-speech-switch">
|
||||||
|
<NTooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="switch-label">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ t('chat.autoPlaySpeech') }}
|
||||||
|
</NTooltip>
|
||||||
|
<NSwitch
|
||||||
|
size="small"
|
||||||
|
v-model:value="autoPlaySpeech"
|
||||||
|
:round="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
|
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
|
||||||
{{ formatTokens(totalTokens) }} / {{ formatTokens(contextLength) }} · {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
|
{{ formatTokens(totalTokens) }} / {{ formatTokens(contextLength) }} · {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -314,6 +353,26 @@ function isImage(type: string): boolean {
|
|||||||
padding: 0 0 6px;
|
padding: 0 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-play-speech-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-left: 1px solid $border-light;
|
||||||
|
margin-left: 4px;
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.context-info {
|
.context-info {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Message } from "@/stores/hermes/chat";
|
import type { Message } from "@/stores/hermes/chat";
|
||||||
import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useMessage } from "naive-ui";
|
import { useMessage } from "naive-ui";
|
||||||
import { downloadFile } from "@/api/hermes/download";
|
import { downloadFile } from "@/api/hermes/download";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
handleCodeBlockCopyClick,
|
handleCodeBlockCopyClick,
|
||||||
renderHighlightedCodeBlock,
|
renderHighlightedCodeBlock,
|
||||||
} from "./highlight";
|
} from "./highlight";
|
||||||
|
import { useGlobalSpeech } from "@/composables/useSpeech";
|
||||||
|
|
||||||
const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
|
const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const previewUrl = ref<string | null>(null);
|
|||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const speech = useGlobalSpeech();
|
||||||
|
|
||||||
// Copy entire bubble content
|
// Copy entire bubble content
|
||||||
const copyableContent = computed(() => {
|
const copyableContent = computed(() => {
|
||||||
@@ -278,6 +280,88 @@ const renderedToolResult = computed(() => {
|
|||||||
toolResultPayload.value.language,
|
toolResultPayload.value.language,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 语音播放相关
|
||||||
|
const canPlaySpeech = computed(() => {
|
||||||
|
// 只有 assistant 消息可以播放,且浏览器支持 Web Speech API
|
||||||
|
return props.message.role === 'assistant' &&
|
||||||
|
speech.isSupported &&
|
||||||
|
copyableContent.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPlayingThisMessage = computed(() => {
|
||||||
|
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPausedThisMessage = computed(() => {
|
||||||
|
return speech.currentMessageId.value === props.message.id && speech.isPaused.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSpeechToggle() {
|
||||||
|
if (!canPlaySpeech.value) {
|
||||||
|
console.log('Speech not supported or no content')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const content = props.message.content || ''
|
||||||
|
console.log('Toggling speech for message:', props.message.id)
|
||||||
|
console.log('Current playing:', speech.currentMessageId.value, speech.isPlaying.value)
|
||||||
|
console.log('Call stack:', new Error().stack)
|
||||||
|
|
||||||
|
// 尝试获取男声语音包
|
||||||
|
const allVoices = speech.getAllVoices()
|
||||||
|
let maleVoice = null
|
||||||
|
|
||||||
|
// 查找可能的男声语音包
|
||||||
|
for (const voice of allVoices) {
|
||||||
|
const name = voice.name.toLowerCase()
|
||||||
|
// 常见男声关键词
|
||||||
|
if (name.includes('male') || name.includes('david') || name.includes('daniel') ||
|
||||||
|
name.includes('mark') || name.includes('yaoyao') || name.includes('google')) {
|
||||||
|
// 优先选择中文男声
|
||||||
|
if (voice.lang.startsWith('zh')) {
|
||||||
|
maleVoice = voice
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 如果没有找到中文男声,记住第一个男声
|
||||||
|
if (!maleVoice) {
|
||||||
|
maleVoice = voice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Selected male voice:', maleVoice?.name, maleVoice?.lang)
|
||||||
|
|
||||||
|
// 快速男声:语速快、音调低
|
||||||
|
speech.toggle(props.message.id, content, {
|
||||||
|
pitch: 0.5, // 低沉
|
||||||
|
rate: 1.2, // 快速
|
||||||
|
voice: maleVoice || undefined, // 使用男声,如果没有就用默认
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听自动播放事件
|
||||||
|
let autoPlayHandler: ((e: Event) => void) | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
autoPlayHandler = (e: Event) => {
|
||||||
|
const customEvent = e as CustomEvent<{ messageId: string; content: string }>
|
||||||
|
if (customEvent.detail.messageId === props.message.id && canPlaySpeech.value) {
|
||||||
|
console.log('Auto-play triggered for message:', props.message.id)
|
||||||
|
handleSpeechToggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('auto-play-speech', autoPlayHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时停止播放并清理事件监听
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (autoPlayHandler) {
|
||||||
|
window.removeEventListener('auto-play-speech', autoPlayHandler)
|
||||||
|
}
|
||||||
|
if (speech.currentMessageId.value === props.message.id) {
|
||||||
|
speech.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -353,7 +437,7 @@ const renderedToolResult = computed(() => {
|
|||||||
class="msg-avatar"
|
class="msg-avatar"
|
||||||
/>
|
/>
|
||||||
<div class="msg-content" :class="message.role">
|
<div class="msg-content" :class="message.role">
|
||||||
<div class="message-bubble" :class="{ system: isSystem }">
|
<div class="message-bubble" :class="{ system: isSystem, 'speech-playing': isPlayingThisMessage && !isPausedThisMessage }">
|
||||||
<div v-if="hasAttachments" class="msg-attachments">
|
<div v-if="hasAttachments" class="msg-attachments">
|
||||||
<div
|
<div
|
||||||
v-for="att in message.attachments"
|
v-for="att in message.attachments"
|
||||||
@@ -442,6 +526,21 @@ const renderedToolResult = computed(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-meta">
|
<div class="message-meta">
|
||||||
|
<button
|
||||||
|
v-if="canPlaySpeech"
|
||||||
|
class="speech-bubble-btn"
|
||||||
|
:class="{ playing: isPlayingThisMessage, paused: isPausedThisMessage }"
|
||||||
|
@click="handleSpeechToggle"
|
||||||
|
:title="isPlayingThisMessage ? (isPausedThisMessage ? t('chat.resumeSpeech') : t('chat.pauseSpeech')) : t('chat.playSpeech')"
|
||||||
|
>
|
||||||
|
<svg v-if="!isPlayingThisMessage || isPausedThisMessage" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="6" y="4" width="4" height="16"/>
|
||||||
|
<rect x="14" y="4" width="4" height="16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="copyableContent"
|
v-if="copyableContent"
|
||||||
class="copy-bubble-btn"
|
class="copy-bubble-btn"
|
||||||
@@ -472,12 +571,15 @@ const renderedToolResult = computed(() => {
|
|||||||
.message {
|
.message {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&.user {
|
&.user {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|
||||||
.msg-body {
|
.msg-body {
|
||||||
max-width: 75%;
|
max-width: 75%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-content.user {
|
.msg-content.user {
|
||||||
@@ -497,6 +599,8 @@ const renderedToolResult = computed(() => {
|
|||||||
|
|
||||||
.msg-body {
|
.msg-body {
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-avatar {
|
.msg-avatar {
|
||||||
@@ -518,13 +622,6 @@ const renderedToolResult = computed(() => {
|
|||||||
|
|
||||||
&.system {
|
&.system {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
||||||
.message-bubble.system {
|
|
||||||
border-left: 3px solid $warning;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
max-width: 80%;
|
|
||||||
background-color: rgba(var(--warning-rgb), 0.06);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.highlight {
|
&.highlight {
|
||||||
@@ -534,6 +631,18 @@ const renderedToolResult = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-flow {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.msg-body {
|
.msg-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -554,6 +663,68 @@ const renderedToolResult = computed(() => {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.system {
|
||||||
|
border-left: 3px solid $warning;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
max-width: 80%;
|
||||||
|
background-color: rgba(var(--warning-rgb), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.speech-playing {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #ff6b6b,
|
||||||
|
0 0 10px rgba(255, 107, 107, 0.4),
|
||||||
|
0 0 20px rgba(255, 107, 107, 0.2);
|
||||||
|
animation: rainbow-glow 4s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rainbow-glow {
|
||||||
|
0% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #ff6b6b,
|
||||||
|
0 0 10px rgba(255, 107, 107, 0.4),
|
||||||
|
0 0 20px rgba(255, 107, 107, 0.2);
|
||||||
|
}
|
||||||
|
16.66% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #feca57,
|
||||||
|
0 0 10px rgba(254, 202, 87, 0.4),
|
||||||
|
0 0 20px rgba(254, 202, 87, 0.2);
|
||||||
|
}
|
||||||
|
33.33% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #48dbfb,
|
||||||
|
0 0 10px rgba(72, 219, 251, 0.4),
|
||||||
|
0 0 20px rgba(72, 219, 251, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #ff9ff3,
|
||||||
|
0 0 10px rgba(255, 159, 243, 0.4),
|
||||||
|
0 0 20px rgba(255, 159, 243, 0.2);
|
||||||
|
}
|
||||||
|
66.66% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #54a0ff,
|
||||||
|
0 0 10px rgba(84, 160, 255, 0.4),
|
||||||
|
0 0 20px rgba(84, 160, 255, 0.2);
|
||||||
|
}
|
||||||
|
83.33% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #5f27cd,
|
||||||
|
0 0 10px rgba(95, 39, 205, 0.4),
|
||||||
|
0 0 20px rgba(95, 39, 205, 0.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #ff6b6b,
|
||||||
|
0 0 10px rgba(255, 107, 107, 0.4),
|
||||||
|
0 0 20px rgba(255, 107, 107, 0.2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-attachments {
|
.msg-attachments {
|
||||||
@@ -673,9 +844,15 @@ const renderedToolResult = computed(() => {
|
|||||||
.message:hover & {
|
.message:hover & {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移动端一直显示按钮
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-bubble-btn {
|
.copy-bubble-btn,
|
||||||
|
.speech-bubble-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -699,11 +876,32 @@ const renderedToolResult = computed(() => {
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #cccccc;
|
color: #cccccc;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.speech-bubble-btn {
|
||||||
|
&.playing {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
|
||||||
|
&.paused {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export interface SpeechOptions {
|
||||||
|
rate?: number // 语速 0.1-10,默认 1
|
||||||
|
pitch?: number // 音调 0-2,默认 1
|
||||||
|
volume?: number // 音量 0-1,默认 1
|
||||||
|
voice?: SpeechSynthesisVoice | null
|
||||||
|
lang?: string // 语言 'zh-CN', 'en-US' 等
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechState {
|
||||||
|
isPlaying: boolean
|
||||||
|
isPaused: boolean
|
||||||
|
currentMessageId: string | null
|
||||||
|
progress: number // 当前进度(字符数)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Speech API 语音播放 Composable
|
||||||
|
*/
|
||||||
|
export function useSpeech() {
|
||||||
|
const synth = window.speechSynthesis
|
||||||
|
const availableVoices = ref<SpeechSynthesisVoice[]>([])
|
||||||
|
const state = ref<SpeechState>({
|
||||||
|
isPlaying: false,
|
||||||
|
isPaused: false,
|
||||||
|
currentMessageId: null,
|
||||||
|
progress: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
let utterance: SpeechSynthesisUtterance | null = null
|
||||||
|
let currentText = ''
|
||||||
|
|
||||||
|
// 加载可用语音列表
|
||||||
|
function loadVoices() {
|
||||||
|
availableVoices.value = synth.getVoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览器会在语音列表变化时触发 voiceschanged 事件
|
||||||
|
synth.addEventListener('voiceschanged', loadVoices)
|
||||||
|
loadVoices() // 初始加载
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中提取纯文本内容,过滤代码块、thinking 标签等
|
||||||
|
*/
|
||||||
|
function extractReadableText(content: string): string {
|
||||||
|
if (!content) return ''
|
||||||
|
|
||||||
|
let text = content
|
||||||
|
|
||||||
|
// 移除 thinking 标签内容
|
||||||
|
text = text.replace(/<thinking[^>]*>[\s\S]*?<\/thinking>/gi, '')
|
||||||
|
text = text.replace(/<thinking[^>]*>[\s\S]*/gi, '')
|
||||||
|
|
||||||
|
// 移除代码块
|
||||||
|
text = text.replace(/```[\s\S]*?```/g, '')
|
||||||
|
text = text.replace(/`[^`]+`/g, '')
|
||||||
|
|
||||||
|
// 移除 HTML 标签
|
||||||
|
text = text.replace(/<[^>]+>/g, '')
|
||||||
|
|
||||||
|
// 移除多余的空白
|
||||||
|
text = text.replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查浏览器是否支持 Web Speech API
|
||||||
|
*/
|
||||||
|
const isSupported = computed(() => {
|
||||||
|
return 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认语音(优先选择中文)
|
||||||
|
*/
|
||||||
|
function getDefaultVoice(): SpeechSynthesisVoice | null {
|
||||||
|
const voices = availableVoices.value
|
||||||
|
if (voices.length === 0) return null
|
||||||
|
|
||||||
|
// 优先选择中文语音
|
||||||
|
const zhVoice = voices.find(v => v.lang.startsWith('zh'))
|
||||||
|
if (zhVoice) return zhVoice
|
||||||
|
|
||||||
|
// 其次选择英文语音
|
||||||
|
const enVoice = voices.find(v => v.lang.startsWith('en'))
|
||||||
|
if (enVoice) return enVoice
|
||||||
|
|
||||||
|
// 默认第一个
|
||||||
|
return voices[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可用语音(用于调试)
|
||||||
|
*/
|
||||||
|
function getAllVoices(): SpeechSynthesisVoice[] {
|
||||||
|
return availableVoices.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止当前播放
|
||||||
|
*/
|
||||||
|
function stop() {
|
||||||
|
if (synth.speaking) {
|
||||||
|
synth.cancel()
|
||||||
|
}
|
||||||
|
if (utterance) {
|
||||||
|
utterance = null
|
||||||
|
}
|
||||||
|
state.value = {
|
||||||
|
isPlaying: false,
|
||||||
|
isPaused: false,
|
||||||
|
currentMessageId: null,
|
||||||
|
progress: 0,
|
||||||
|
}
|
||||||
|
currentText = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放文本
|
||||||
|
*/
|
||||||
|
function play(messageId: string, content: string, options: SpeechOptions = {}) {
|
||||||
|
if (!isSupported.value) {
|
||||||
|
console.warn('[useSpeech] Speech synthesis not supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useSpeech] play called:', messageId)
|
||||||
|
|
||||||
|
// 如果正在播放其他消息,先停止
|
||||||
|
if (state.value.currentMessageId && state.value.currentMessageId !== messageId) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经在播放这条消息,暂停/恢复
|
||||||
|
if (state.value.currentMessageId === messageId) {
|
||||||
|
if (state.value.isPaused) {
|
||||||
|
resume()
|
||||||
|
} else if (state.value.isPlaying) {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取可读文本
|
||||||
|
const text = extractReadableText(content)
|
||||||
|
if (!text) {
|
||||||
|
console.warn('[useSpeech] No readable text found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useSpeech] Playing text:', text.substring(0, 50) + '...')
|
||||||
|
|
||||||
|
// 停止当前播放
|
||||||
|
stop()
|
||||||
|
|
||||||
|
// 创建新的 utterance
|
||||||
|
utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
currentText = text
|
||||||
|
|
||||||
|
// 设置语音参数
|
||||||
|
utterance.rate = options.rate ?? 1
|
||||||
|
utterance.pitch = options.pitch ?? 1
|
||||||
|
utterance.volume = options.volume ?? 1
|
||||||
|
utterance.voice = options.voice ?? getDefaultVoice()
|
||||||
|
|
||||||
|
console.log('[useSpeech] Selected voice:', utterance.voice?.name, utterance.voice?.lang)
|
||||||
|
|
||||||
|
if (options.lang) {
|
||||||
|
utterance.lang = options.lang
|
||||||
|
} else if (utterance.voice) {
|
||||||
|
utterance.lang = utterance.voice.lang
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件监听
|
||||||
|
utterance.onstart = () => {
|
||||||
|
console.log('[useSpeech] onstart fired')
|
||||||
|
state.value.isPlaying = true
|
||||||
|
state.value.isPaused = false
|
||||||
|
state.value.currentMessageId = messageId
|
||||||
|
state.value.progress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.onboundary = (event) => {
|
||||||
|
if (event.name === 'word') {
|
||||||
|
state.value.progress = event.charIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.onend = () => {
|
||||||
|
console.log('[useSpeech] onend fired')
|
||||||
|
state.value.isPlaying = false
|
||||||
|
state.value.isPaused = false
|
||||||
|
state.value.currentMessageId = null
|
||||||
|
state.value.progress = currentText.length
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.onerror = (event) => {
|
||||||
|
console.error('[useSpeech] Speech synthesis error:', event.error)
|
||||||
|
state.value.isPlaying = false
|
||||||
|
state.value.isPaused = false
|
||||||
|
state.value.currentMessageId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始播放
|
||||||
|
console.log('[useSpeech] Calling synth.speak()')
|
||||||
|
synth.speak(utterance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停播放
|
||||||
|
*/
|
||||||
|
function pause() {
|
||||||
|
if (synth.speaking && !state.value.isPaused) {
|
||||||
|
synth.pause()
|
||||||
|
state.value.isPaused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复播放
|
||||||
|
*/
|
||||||
|
function resume() {
|
||||||
|
if (state.value.isPaused) {
|
||||||
|
synth.resume()
|
||||||
|
state.value.isPaused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换播放/暂停
|
||||||
|
*/
|
||||||
|
function toggle(messageId: string, content: string, options: SpeechOptions = {}) {
|
||||||
|
if (state.value.currentMessageId === messageId && state.value.isPlaying) {
|
||||||
|
if (state.value.isPaused) {
|
||||||
|
resume()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
play(messageId, content, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
synth.removeEventListener('voiceschanged', loadVoices)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isSupported,
|
||||||
|
availableVoices,
|
||||||
|
isPlaying: computed(() => state.value.isPlaying),
|
||||||
|
isPaused: computed(() => state.value.isPaused),
|
||||||
|
currentMessageId: computed(() => state.value.currentMessageId),
|
||||||
|
progress: computed(() => state.value.progress),
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
toggle,
|
||||||
|
getDefaultVoice,
|
||||||
|
getAllVoices,
|
||||||
|
extractReadableText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例模式,全局共享一个语音实例
|
||||||
|
let globalSpeech: ReturnType<typeof useSpeech> | null = null
|
||||||
|
|
||||||
|
export function useGlobalSpeech() {
|
||||||
|
if (!globalSpeech) {
|
||||||
|
globalSpeech = useSpeech()
|
||||||
|
}
|
||||||
|
return globalSpeech
|
||||||
|
}
|
||||||
@@ -147,6 +147,11 @@ export default {
|
|||||||
copyBubble: 'Nachricht kopieren',
|
copyBubble: 'Nachricht kopieren',
|
||||||
copiedBubble: 'Nachricht kopiert',
|
copiedBubble: 'Nachricht kopiert',
|
||||||
copyFailed: 'Kopieren fehlgeschlagen',
|
copyFailed: 'Kopieren fehlgeschlagen',
|
||||||
|
playSpeech: 'Sprache abspielen',
|
||||||
|
pauseSpeech: 'Pause',
|
||||||
|
resumeSpeech: 'Fortsetzen',
|
||||||
|
stopSpeech: 'Stoppen',
|
||||||
|
speechNotSupported: 'Sprachwiedergabe in diesem Browser nicht unterstützt',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export default {
|
|||||||
emptyState: 'Start a conversation with Hermes Agent',
|
emptyState: 'Start a conversation with Hermes Agent',
|
||||||
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
|
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
|
||||||
attachFiles: 'Attach files',
|
attachFiles: 'Attach files',
|
||||||
|
autoPlaySpeech: 'Auto-play voice',
|
||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
stopGateway: 'Stop Gateway',
|
stopGateway: 'Stop Gateway',
|
||||||
@@ -176,6 +177,11 @@ export default {
|
|||||||
copyBubble: 'Copy message',
|
copyBubble: 'Copy message',
|
||||||
copiedBubble: 'Message copied',
|
copiedBubble: 'Message copied',
|
||||||
copyFailed: 'Copy failed',
|
copyFailed: 'Copy failed',
|
||||||
|
playSpeech: 'Play voice',
|
||||||
|
pauseSpeech: 'Pause',
|
||||||
|
resumeSpeech: 'Resume',
|
||||||
|
stopSpeech: 'Stop',
|
||||||
|
speechNotSupported: 'Voice playback not supported in this browser',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export default {
|
|||||||
copyBubble: 'Copiar mensaje',
|
copyBubble: 'Copiar mensaje',
|
||||||
copiedBubble: 'Mensaje copiado',
|
copiedBubble: 'Mensaje copiado',
|
||||||
copyFailed: 'Error al copiar',
|
copyFailed: 'Error al copiar',
|
||||||
|
playSpeech: 'Reproducir voz',
|
||||||
|
pauseSpeech: 'Pausa',
|
||||||
|
resumeSpeech: 'Reanudar',
|
||||||
|
stopSpeech: 'Detener',
|
||||||
|
speechNotSupported: 'Reproducción de voz no soportada en este navegador',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export default {
|
|||||||
copyBubble: 'Copier le message',
|
copyBubble: 'Copier le message',
|
||||||
copiedBubble: 'Message copié',
|
copiedBubble: 'Message copié',
|
||||||
copyFailed: 'Échec de la copie',
|
copyFailed: 'Échec de la copie',
|
||||||
|
playSpeech: 'Lire à voix haute',
|
||||||
|
pauseSpeech: 'Pause',
|
||||||
|
resumeSpeech: 'Reprendre',
|
||||||
|
stopSpeech: 'Arrêter',
|
||||||
|
speechNotSupported: 'Reproduction vocale non prise en charge dans ce navigateur',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export default {
|
|||||||
copyBubble: 'メッセージをコピー',
|
copyBubble: 'メッセージをコピー',
|
||||||
copiedBubble: 'コピーしました',
|
copiedBubble: 'コピーしました',
|
||||||
copyFailed: 'コピーに失敗しました',
|
copyFailed: 'コピーに失敗しました',
|
||||||
|
playSpeech: '音声を読み上げ',
|
||||||
|
pauseSpeech: '一時停止',
|
||||||
|
resumeSpeech: '再開',
|
||||||
|
stopSpeech: '停止',
|
||||||
|
speechNotSupported: 'このブラウザは音声読み上げをサポートしていません',
|
||||||
},
|
},
|
||||||
|
|
||||||
// スケジュールジョブ
|
// スケジュールジョブ
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export default {
|
|||||||
copyBubble: '메시지 복사',
|
copyBubble: '메시지 복사',
|
||||||
copiedBubble: '복사됨',
|
copiedBubble: '복사됨',
|
||||||
copyFailed: '복사 실패',
|
copyFailed: '복사 실패',
|
||||||
|
playSpeech: '음성 재생',
|
||||||
|
pauseSpeech: '일시정지',
|
||||||
|
resumeSpeech: '재개',
|
||||||
|
stopSpeech: '중지',
|
||||||
|
speechNotSupported: '이 브라우저는 음성 재생을 지원하지 않습니다',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 예약 작업
|
// 예약 작업
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export default {
|
|||||||
copyBubble: 'Copiar mensagem',
|
copyBubble: 'Copiar mensagem',
|
||||||
copiedBubble: 'Mensagem copiada',
|
copiedBubble: 'Mensagem copiada',
|
||||||
copyFailed: 'Falha ao copiar',
|
copyFailed: 'Falha ao copiar',
|
||||||
|
playSpeech: 'Reproduzir voz',
|
||||||
|
pauseSpeech: 'Pausar',
|
||||||
|
resumeSpeech: 'Retomar',
|
||||||
|
stopSpeech: 'Parar',
|
||||||
|
speechNotSupported: 'Reprodução de voz não suportada neste navegador',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export default {
|
|||||||
emptyState: '开始与 Hermes Agent 对话',
|
emptyState: '开始与 Hermes Agent 对话',
|
||||||
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
||||||
attachFiles: '添加附件',
|
attachFiles: '添加附件',
|
||||||
|
autoPlaySpeech: '自动播放语音',
|
||||||
stop: '停止',
|
stop: '停止',
|
||||||
start: '启动',
|
start: '启动',
|
||||||
stopGateway: '停止网关',
|
stopGateway: '停止网关',
|
||||||
@@ -176,6 +177,11 @@ export default {
|
|||||||
copyBubble: '复制消息',
|
copyBubble: '复制消息',
|
||||||
copiedBubble: '已复制',
|
copiedBubble: '已复制',
|
||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
|
playSpeech: '播放语音',
|
||||||
|
pauseSpeech: '暂停',
|
||||||
|
resumeSpeech: '继续',
|
||||||
|
stopSpeech: '停止',
|
||||||
|
speechNotSupported: '此浏览器不支持语音播放',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 定时任务
|
// 定时任务
|
||||||
|
|||||||
@@ -300,6 +300,13 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const streamStates = ref<Map<string, { abort: () => void }>>(new Map())
|
const streamStates = ref<Map<string, { abort: () => void }>>(new Map())
|
||||||
/** sessionId → server-reported isWorking status */
|
/** sessionId → server-reported isWorking status */
|
||||||
const serverWorking = ref<Set<string>>(new Set())
|
const serverWorking = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 自动播放语音开关
|
||||||
|
const autoPlaySpeechEnabled = ref(false)
|
||||||
|
|
||||||
|
function setAutoPlaySpeech(enabled: boolean) {
|
||||||
|
autoPlaySpeechEnabled.value = enabled
|
||||||
|
}
|
||||||
const isStreaming = computed(() => {
|
const isStreaming = computed(() => {
|
||||||
const sid = activeSessionId.value
|
const sid = activeSessionId.value
|
||||||
if (sid == null) return false
|
if (sid == null) return false
|
||||||
@@ -871,6 +878,19 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动播放语音
|
||||||
|
console.log('[run.completed] autoPlaySpeechEnabled:', autoPlaySpeechEnabled.value)
|
||||||
|
if (autoPlaySpeechEnabled.value) {
|
||||||
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')
|
||||||
|
if (lastAssistant?.content) {
|
||||||
|
// 延迟一小会儿再播放,确保 UI 更新完成
|
||||||
|
setTimeout(() => {
|
||||||
|
playMessageSpeech(lastAssistant.id, lastAssistant.content)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
// the in-flight marker. If the browser is reloading right now
|
// the in-flight marker. If the browser is reloading right now
|
||||||
@@ -1342,6 +1362,15 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
thinkingObservation.clear()
|
thinkingObservation.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 播放消息语音
|
||||||
|
function playMessageSpeech(messageId: string, content: string) {
|
||||||
|
// 触发自定义事件,让 MessageItem 组件处理播放
|
||||||
|
const event = new CustomEvent('auto-play-speech', {
|
||||||
|
detail: { messageId, content }
|
||||||
|
})
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
@@ -1371,5 +1400,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
noteReasoningStart,
|
noteReasoningStart,
|
||||||
noteReasoningEnd,
|
noteReasoningEnd,
|
||||||
clearThinkingObservationFor,
|
clearThinkingObservationFor,
|
||||||
|
setAutoPlaySpeech,
|
||||||
|
playMessageSpeech,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user