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>
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetCompletionSoundForTests, playCompletionSound, primeCompletionSound } from '@/utils/completion-sound'
function installMockAudioContext(initialState: AudioContextState = 'running') {
const oscillator = {
type: 'sine' as OscillatorType,
frequency: {
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}
const gain = {
gain: {
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
}
const context = {
state: initialState,
currentTime: 10,
destination: {},
resume: vi.fn(async () => {
context.state = 'running'
}),
createOscillator: vi.fn(() => oscillator),
createGain: vi.fn(() => gain),
}
const AudioContextMock = vi.fn(() => context)
Object.defineProperty(window, 'AudioContext', {
configurable: true,
writable: true,
value: AudioContextMock,
})
return { AudioContextMock, context, oscillator, gain }
}
describe('completion sound', () => {
beforeEach(() => {
__resetCompletionSoundForTests()
vi.restoreAllMocks()
Object.defineProperty(window, 'AudioContext', {
configurable: true,
writable: true,
value: undefined,
})
})
it('returns false when Web Audio is unavailable', async () => {
await expect(playCompletionSound()).resolves.toBe(false)
})
it('primes a suspended audio context from user interaction', () => {
const { context } = installMockAudioContext('suspended')
primeCompletionSound()
expect(context.resume).toHaveBeenCalledTimes(1)
})
it('plays a short tone through Web Audio', async () => {
const { context, oscillator, gain } = installMockAudioContext('running')
await expect(playCompletionSound()).resolves.toBe(true)
expect(context.createOscillator).toHaveBeenCalledTimes(1)
expect(context.createGain).toHaveBeenCalledTimes(1)
expect(oscillator.connect).toHaveBeenCalledWith(gain)
expect(gain.connect).toHaveBeenCalledWith(context.destination)
expect(oscillator.start).toHaveBeenCalledWith(10)
expect(oscillator.stop).toHaveBeenCalledWith(10.16)
})
})