fix: play completion sound in chat (#466)

This commit is contained in:
Zhicheng Han
2026-05-06 08:23:12 +02:00
committed by GitHub
parent 1011c950be
commit f338aeea18
4 changed files with 177 additions and 3 deletions
+20 -1
View File
@@ -5,6 +5,8 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAppStore } from './app'
import { useProfilesStore } from './profiles'
import { useSettingsStore } from './settings'
import { primeCompletionSound, playCompletionSound } from '@/utils/completion-sound'
import { detectThinkingBoundary } from '@/utils/thinking-parser'
// Re-export ContentBlock for convenience
@@ -581,9 +583,23 @@ export const useChatStore = defineStore('chat', () => {
target.updatedAt = Date.now()
}
function primeCompletionBellIfEnabled() {
if (useSettingsStore().display.bell_on_complete) {
primeCompletionSound()
}
}
function playCompletionBellIfEnabled() {
if (useSettingsStore().display.bell_on_complete) {
void playCompletionSound()
}
}
async function sendMessage(content: string, attachments?: Attachment[]) {
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
primeCompletionBellIfEnabled()
if (!activeSession.value) {
const session = createSession()
switchSession(session.id)
@@ -904,6 +920,8 @@ export const useChatStore = defineStore('chat', () => {
content: 'Error: Agent returned no output. The model call may have failed (e.g. invalid API key, model not supported by provider, or context exceeded). Check the hermes-agent logs for details.',
timestamp: Date.now(),
})
} else {
playCompletionBellIfEnabled()
}
// 自动播放语音
@@ -1236,9 +1254,10 @@ export const useChatStore = defineStore('chat', () => {
content: 'Error: Agent returned no output. The model call may have failed (e.g. invalid API key, model not supported by provider, or context exceeded). Check the hermes-agent logs for details.',
timestamp: Date.now(),
})
} else {
playCompletionBellIfEnabled()
}
cleanup()
updateSessionTitle(sid)
break
@@ -0,0 +1,68 @@
type AudioContextConstructor = typeof AudioContext
type WindowWithWebkitAudio = Window & typeof globalThis & {
webkitAudioContext?: AudioContextConstructor
}
let audioContext: AudioContext | null = null
function getAudioContext(): AudioContext | null {
if (typeof window === 'undefined') return null
const AudioContextCtor = window.AudioContext || (window as WindowWithWebkitAudio).webkitAudioContext
if (!AudioContextCtor) return null
if (!audioContext) {
audioContext = new AudioContextCtor()
}
return audioContext
}
export function primeCompletionSound(): void {
const ctx = getAudioContext()
if (!ctx || ctx.state !== 'suspended') return
void ctx.resume().catch(() => {
// Browser autoplay policy may still reject until a user gesture. Ignore; the
// next send action will try again.
})
}
export async function playCompletionSound(): Promise<boolean> {
const ctx = getAudioContext()
if (!ctx) return false
try {
if (ctx.state === 'suspended') {
await ctx.resume()
}
const now = ctx.currentTime
const duration = 0.16
const oscillator = ctx.createOscillator()
const gain = ctx.createGain()
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(880, now)
oscillator.frequency.exponentialRampToValueAtTime(660, now + duration)
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(0.18, now + 0.015)
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
oscillator.connect(gain)
gain.connect(ctx.destination)
oscillator.start(now)
oscillator.stop(now + duration)
return true
} catch (err) {
console.warn('Failed to play completion sound:', err)
return false
}
}
export function __resetCompletionSoundForTests(): void {
audioContext = null
}
@@ -4,15 +4,21 @@ import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useSettingsStore } from '@/stores/hermes/settings'
const appStore = useAppStore()
const chatStore = useChatStore()
const profilesStore = useProfilesStore()
const settingsStore = useSettingsStore()
onMounted(async () => {
appStore.loadModels()
// 先加载 profile,确保缓存 key 使用正确的 profile name
await profilesStore.fetchProfiles()
// 先加载 profile,确保缓存 key 使用正确的 profile name;同时预取显示设置,
// 让聊天完成提示音不依赖用户先打开 Settings 页面。
await Promise.all([
profilesStore.fetchProfiles(),
settingsStore.fetchSettings(),
])
chatStore.loadSessions()
})
</script>