fix: play completion sound in chat (#466)
This commit is contained in:
@@ -5,6 +5,8 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useAppStore } from './app'
|
import { useAppStore } from './app'
|
||||||
import { useProfilesStore } from './profiles'
|
import { useProfilesStore } from './profiles'
|
||||||
|
import { useSettingsStore } from './settings'
|
||||||
|
import { primeCompletionSound, playCompletionSound } from '@/utils/completion-sound'
|
||||||
import { detectThinkingBoundary } from '@/utils/thinking-parser'
|
import { detectThinkingBoundary } from '@/utils/thinking-parser'
|
||||||
|
|
||||||
// Re-export ContentBlock for convenience
|
// Re-export ContentBlock for convenience
|
||||||
@@ -581,9 +583,23 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
target.updatedAt = Date.now()
|
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[]) {
|
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||||
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
|
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
|
||||||
|
|
||||||
|
primeCompletionBellIfEnabled()
|
||||||
|
|
||||||
if (!activeSession.value) {
|
if (!activeSession.value) {
|
||||||
const session = createSession()
|
const session = createSession()
|
||||||
switchSession(session.id)
|
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.',
|
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(),
|
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.',
|
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(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
playCompletionBellIfEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
break
|
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 { useAppStore } from '@/stores/hermes/app'
|
||||||
import { useChatStore } from '@/stores/hermes/chat'
|
import { useChatStore } from '@/stores/hermes/chat'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
|
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
appStore.loadModels()
|
appStore.loadModels()
|
||||||
// 先加载 profile,确保缓存 key 使用正确的 profile name
|
// 先加载 profile,确保缓存 key 使用正确的 profile name;同时预取显示设置,
|
||||||
await profilesStore.fetchProfiles()
|
// 让聊天完成提示音不依赖用户先打开 Settings 页面。
|
||||||
|
await Promise.all([
|
||||||
|
profilesStore.fetchProfiles(),
|
||||||
|
settingsStore.fetchSettings(),
|
||||||
|
])
|
||||||
chatStore.loadSessions()
|
chatStore.loadSessions()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user