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:
ekko
2026-05-02 13:26:57 +08:00
committed by GitHub
parent 4c8cff2e7c
commit caa9162f28
12 changed files with 624 additions and 13 deletions
+31
View File
@@ -300,6 +300,13 @@ export const useChatStore = defineStore('chat', () => {
const streamStates = ref<Map<string, { abort: () => void }>>(new Map())
/** sessionId → server-reported isWorking status */
const serverWorking = ref<Set<string>>(new Set())
// 自动播放语音开关
const autoPlaySpeechEnabled = ref(false)
function setAutoPlaySpeech(enabled: boolean) {
autoPlaySpeechEnabled.value = enabled
}
const isStreaming = computed(() => {
const sid = activeSessionId.value
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()
updateSessionTitle(sid)
// the in-flight marker. If the browser is reloading right now
@@ -1342,6 +1362,15 @@ export const useChatStore = defineStore('chat', () => {
thinkingObservation.clear()
}
// 播放消息语音
function playMessageSpeech(messageId: string, content: string) {
// 触发自定义事件,让 MessageItem 组件处理播放
const event = new CustomEvent('auto-play-speech', {
detail: { messageId, content }
})
window.dispatchEvent(event)
}
return {
sessions,
activeSessionId,
@@ -1371,5 +1400,7 @@ export const useChatStore = defineStore('chat', () => {
noteReasoningStart,
noteReasoningEnd,
clearThinkingObservationFor,
setAutoPlaySpeech,
playMessageSpeech,
}
})