mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-20 23:40:20 +00:00
510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { readFileSync } from 'node:fs'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const mockFetch = vi.fn()
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const createObjectURL = vi.fn()
|
|
const revokeObjectURL = vi.fn()
|
|
vi.stubGlobal('URL', {
|
|
createObjectURL,
|
|
revokeObjectURL,
|
|
})
|
|
|
|
const audioInstances: MockAudio[] = []
|
|
let objectUrlCounter = 0
|
|
|
|
class MockAudio {
|
|
src: string
|
|
onended: (() => void) | null = null
|
|
onerror: (() => void) | null = null
|
|
play = vi.fn().mockResolvedValue(undefined)
|
|
pause = vi.fn()
|
|
|
|
constructor(src = '') {
|
|
this.src = src
|
|
audioInstances.push(this)
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal('Audio', MockAudio)
|
|
|
|
function flushPromises() {
|
|
return new Promise(resolve => setTimeout(resolve, 0))
|
|
}
|
|
|
|
function installSpeechSynthesisMock() {
|
|
Object.defineProperty(window, 'speechSynthesis', {
|
|
configurable: true,
|
|
value: {
|
|
speaking: false,
|
|
pending: false,
|
|
paused: false,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
getVoices: vi.fn(() => []),
|
|
speak: vi.fn(),
|
|
cancel: vi.fn(),
|
|
pause: vi.fn(),
|
|
resume: vi.fn(),
|
|
},
|
|
})
|
|
}
|
|
|
|
describe('client TTS unified synthesize flow', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
localStorage.clear()
|
|
localStorage.setItem('hermes_server_url', 'https://hermes.example')
|
|
localStorage.setItem('hermes_api_key', 'secret-key')
|
|
objectUrlCounter = 0
|
|
createObjectURL.mockImplementation(() => `blob:mock-${++objectUrlCounter}`)
|
|
audioInstances.length = 0
|
|
installSpeechSynthesisMock()
|
|
})
|
|
|
|
it('synthesizeSpeech posts to the unified endpoint with auth, body, and signal', async () => {
|
|
localStorage.setItem('hermes_active_profile_name', 'research')
|
|
mockFetch.mockResolvedValue(new Response('audio-bytes', {
|
|
status: 200,
|
|
headers: {
|
|
'X-TTS-Engine': 'openai-engine',
|
|
'X-TTS-Provider': 'openai',
|
|
'Content-Type': 'audio/mpeg',
|
|
},
|
|
}))
|
|
|
|
const { synthesizeSpeech } = await import('../../packages/client/src/api/hermes/tts')
|
|
const controller = new AbortController()
|
|
|
|
const result = await synthesizeSpeech({
|
|
provider: 'openai',
|
|
text: 'Hello world',
|
|
options: { voice: 'alloy', model: 'tts-1' },
|
|
signal: controller.signal,
|
|
})
|
|
|
|
expect(mockFetch).toHaveBeenCalledOnce()
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/tts/synthesize',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: 'Bearer secret-key',
|
|
'X-Hermes-Profile': 'research',
|
|
},
|
|
body: JSON.stringify({
|
|
provider: 'openai',
|
|
text: 'Hello world',
|
|
options: { voice: 'alloy', model: 'tts-1' },
|
|
}),
|
|
signal: controller.signal,
|
|
},
|
|
)
|
|
expect(result.audio).toBeInstanceOf(Blob)
|
|
expect(result.audio.size).toBeGreaterThan(0)
|
|
expect(result.engine).toBe('openai-engine')
|
|
expect(result.provider).toBe('openai')
|
|
})
|
|
|
|
it('openaiPlay routes through the unified synthesize endpoint with provider=openai', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: {
|
|
'X-TTS-Engine': 'openai',
|
|
'X-TTS-Provider': 'openai',
|
|
},
|
|
}))
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-openai', 'Hello from OpenAI', {
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
apiKey: 'provider-key',
|
|
model: 'tts-1',
|
|
voice: 'alloy',
|
|
})
|
|
|
|
expect(mockFetch).toHaveBeenCalledOnce()
|
|
const [url, options] = mockFetch.mock.calls[0]
|
|
expect(url).toBe('https://hermes.example/api/hermes/tts/synthesize')
|
|
expect(url).not.toContain('/audio/speech')
|
|
expect(JSON.parse(options.body)).toEqual({
|
|
provider: 'openai',
|
|
text: 'Hello from OpenAI',
|
|
options: {
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
apiKey: 'provider-key',
|
|
model: 'tts-1',
|
|
voice: 'alloy',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('mimoPlay routes through the unified synthesize endpoint with provider=mimo', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['mimo-audio'], { type: 'audio/wav' }), {
|
|
status: 200,
|
|
headers: {
|
|
'X-TTS-Engine': 'mimo',
|
|
'X-TTS-Provider': 'mimo',
|
|
},
|
|
}))
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.mimoPlay('msg-mimo', 'Hello from MiMo', {
|
|
baseUrl: 'https://mimo.example/v1',
|
|
apiKey: 'mimo-key',
|
|
authMode: 'api-key',
|
|
model: 'mimo-v2.5-tts',
|
|
voiceMode: 'voiceClone',
|
|
voice: 'verse',
|
|
voiceCloneDataUri: 'data:audio/wav;base64,ZmFrZQ==',
|
|
voiceCloneFormat: 'wav',
|
|
stylePrompt: 'warm and calm',
|
|
})
|
|
|
|
expect(mockFetch).toHaveBeenCalledOnce()
|
|
const [url, options] = mockFetch.mock.calls[0]
|
|
expect(url).toBe('https://hermes.example/api/hermes/tts/synthesize')
|
|
expect(url).not.toContain('/chat/completions')
|
|
expect(JSON.parse(options.body)).toEqual({
|
|
provider: 'mimo',
|
|
text: 'Hello from MiMo',
|
|
options: {
|
|
baseUrl: 'https://mimo.example/v1',
|
|
apiKey: 'mimo-key',
|
|
authMode: 'api-key',
|
|
model: 'mimo-v2.5-tts',
|
|
voiceMode: 'voiceClone',
|
|
voice: 'verse',
|
|
voiceCloneDataUri: 'data:audio/wav;base64,ZmFrZQ==',
|
|
voiceCloneFormat: 'wav',
|
|
stylePrompt: 'warm and calm',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('openaiPlay respects an explicit edge provider even when other fields look OpenAI-compatible', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['edge-audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: { 'X-TTS-Provider': 'edge' },
|
|
}))
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-edge-explicit', 'Edge explicit', {
|
|
provider: 'edge',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
model: 'tts-1',
|
|
voice: 'zh-CN-XiaoxiaoNeural',
|
|
})
|
|
|
|
const [, options] = mockFetch.mock.calls[0]
|
|
expect(JSON.parse(options.body).provider).toBe('edge')
|
|
})
|
|
|
|
it('openaiPlay falls back to edge for the legacy /api/tts/proxy baseUrl', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['edge-audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: { 'X-TTS-Provider': 'edge' },
|
|
}))
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-edge-fallback', 'Edge fallback', {
|
|
baseUrl: '/api/tts/proxy',
|
|
voice: 'zh-CN-XiaoxiaoNeural',
|
|
})
|
|
|
|
const [, options] = mockFetch.mock.calls[0]
|
|
expect(JSON.parse(options.body).provider).toBe('edge')
|
|
})
|
|
|
|
it('openaiPlay falls back to custom for non-proxy endpoints without a model', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['custom-audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: { 'X-TTS-Provider': 'custom' },
|
|
}))
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-custom-fallback', 'Custom fallback', {
|
|
baseUrl: 'https://custom.example/v1',
|
|
apiKey: 'custom-key',
|
|
})
|
|
|
|
const [, options] = mockFetch.mock.calls[0]
|
|
expect(JSON.parse(options.body).provider).toBe('custom')
|
|
})
|
|
|
|
it('stop aborts a pending unified custom TTS request and clears custom state', async () => {
|
|
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
|
|
let capturedSignal: AbortSignal | undefined
|
|
|
|
mockFetch.mockImplementation((_url, options: RequestInit) => {
|
|
capturedSignal = options.signal as AbortSignal
|
|
return new Promise((_resolve, reject) => {
|
|
capturedSignal?.addEventListener('abort', () => reject(abortError), { once: true })
|
|
})
|
|
})
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
const pending = speech.openaiPlay('msg-stop', 'Stop me', {
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
|
|
expect(speech.isCustomPlaying.value).toBe(true)
|
|
expect(speech.isCustomPaused.value).toBe(false)
|
|
expect(speech.currentCustomMessageId.value).toBe('msg-stop')
|
|
|
|
speech.stop()
|
|
|
|
expect(capturedSignal?.aborted).toBe(true)
|
|
expect(speech.isCustomPlaying.value).toBe(false)
|
|
expect(speech.isCustomPaused.value).toBe(false)
|
|
expect(speech.currentCustomMessageId.value).toBe(null)
|
|
|
|
await pending.catch(() => undefined)
|
|
})
|
|
|
|
it('stop(true) clears queued playback and stops the active TTS audio', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: { 'X-TTS-Engine': 'edge' },
|
|
}))
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
speech.enqueue('msg-1', 'First queued response')
|
|
await flushPromises()
|
|
const [firstAudio] = audioInstances
|
|
|
|
speech.enqueue('msg-2', 'Second queued response')
|
|
speech.stop(true)
|
|
firstAudio.onended?.()
|
|
await flushPromises()
|
|
|
|
expect(firstAudio.pause).toHaveBeenCalledOnce()
|
|
expect(firstAudio.src).toBe('')
|
|
expect(audioInstances).toHaveLength(1)
|
|
expect(speech.currentMessageId.value).toBe(null)
|
|
expect(speech.isPlaying.value).toBe(false)
|
|
})
|
|
|
|
it('handles AbortError silently without console.error and clears custom state', async () => {
|
|
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
mockFetch.mockRejectedValue(abortError)
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await expect(speech.openaiPlay('msg-abort', 'Abort me', {
|
|
baseUrl: 'https://custom.example/v1',
|
|
})).resolves.toBeUndefined()
|
|
|
|
expect(consoleError).not.toHaveBeenCalled()
|
|
expect(speech.isCustomPlaying.value).toBe(false)
|
|
expect(speech.isCustomPaused.value).toBe(false)
|
|
expect(speech.currentCustomMessageId.value).toBe(null)
|
|
})
|
|
|
|
it('openaiToggle handles request failures without an unhandled rejection', async () => {
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
mockFetch.mockRejectedValue(new Error('upstream failed'))
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
speech.openaiToggle('msg-toggle-fail', 'Fail through toggle', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(consoleError).not.toHaveBeenCalled()
|
|
expect(speech.isCustomPlaying.value).toBe(false)
|
|
expect(speech.isCustomPaused.value).toBe(false)
|
|
expect(speech.currentCustomMessageId.value).toBe(null)
|
|
})
|
|
|
|
it('handles custom audio resume play() rejection', async () => {
|
|
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: { 'X-TTS-Provider': 'custom' },
|
|
}))
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-resume', 'Audio to resume', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
const [audio] = audioInstances
|
|
|
|
speech.openaiToggle('msg-resume', 'Audio to resume', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
audio.play.mockRejectedValueOnce(new Error('play blocked'))
|
|
speech.openaiToggle('msg-resume', 'Audio to resume', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(consoleWarn).toHaveBeenCalledWith('[useSpeech] Custom TTS audio resume failed:', expect.any(Error))
|
|
expect(speech.isCustomPaused.value).toBe(true)
|
|
})
|
|
|
|
it('pause() pauses active custom audio and preserves the current custom message', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: { 'X-TTS-Provider': 'custom' },
|
|
}))
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-custom-pause', 'Audio to pause', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
const [audio] = audioInstances
|
|
|
|
speech.pause()
|
|
|
|
expect(audio.pause).toHaveBeenCalledOnce()
|
|
expect(speech.isCustomPlaying.value).toBe(true)
|
|
expect(speech.isCustomPaused.value).toBe(true)
|
|
expect(speech.currentCustomMessageId.value).toBe('msg-custom-pause')
|
|
})
|
|
|
|
it('resume() resumes paused custom audio and clears the custom paused flag', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: { 'X-TTS-Provider': 'custom' },
|
|
}))
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-custom-resume', 'Audio to resume globally', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
const [audio] = audioInstances
|
|
speech.pause()
|
|
audio.play.mockClear()
|
|
|
|
speech.resume()
|
|
await flushPromises()
|
|
|
|
expect(audio.play).toHaveBeenCalledOnce()
|
|
expect(speech.isCustomPlaying.value).toBe(true)
|
|
expect(speech.isCustomPaused.value).toBe(false)
|
|
expect(speech.currentCustomMessageId.value).toBe('msg-custom-resume')
|
|
})
|
|
|
|
it('same-message toggle during pending custom synthesis aborts before audio playback starts', async () => {
|
|
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
|
|
let capturedSignal: AbortSignal | undefined
|
|
|
|
mockFetch.mockImplementation((_url, options: RequestInit) => {
|
|
capturedSignal = options.signal as AbortSignal
|
|
return new Promise((_resolve, reject) => {
|
|
capturedSignal?.addEventListener('abort', () => reject(abortError), { once: true })
|
|
})
|
|
})
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
speech.openaiToggle('msg-pending-toggle', 'Audio still synthesizing', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
expect(speech.isCustomPlaying.value).toBe(true)
|
|
|
|
speech.openaiToggle('msg-pending-toggle', 'Audio still synthesizing', {
|
|
provider: 'custom',
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(capturedSignal?.aborted).toBe(true)
|
|
expect(audioInstances).toHaveLength(0)
|
|
expect(speech.isCustomPlaying.value).toBe(false)
|
|
expect(speech.isCustomPaused.value).toBe(false)
|
|
expect(speech.currentCustomMessageId.value).toBe(null)
|
|
})
|
|
|
|
it('revokes custom audio object URLs when stop() stops custom playback', async () => {
|
|
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
|
|
status: 200,
|
|
headers: {
|
|
'X-TTS-Engine': 'openai',
|
|
'X-TTS-Provider': 'custom',
|
|
},
|
|
}))
|
|
|
|
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
|
|
const speech = useSpeech()
|
|
|
|
await speech.openaiPlay('msg-audio', 'Audio to stop', {
|
|
baseUrl: 'https://custom.example/v1',
|
|
})
|
|
|
|
expect(createObjectURL).toHaveBeenCalledOnce()
|
|
expect(audioInstances).toHaveLength(1)
|
|
const [audio] = audioInstances
|
|
|
|
const createdUrl = createObjectURL.mock.results[0]?.value
|
|
|
|
speech.stop()
|
|
|
|
expect(audio.pause).toHaveBeenCalledOnce()
|
|
expect(audio.src).toBe('')
|
|
expect(revokeObjectURL).toHaveBeenCalledWith(createdUrl)
|
|
expect(speech.isCustomPlaying.value).toBe(false)
|
|
expect(speech.currentCustomMessageId.value).toBe(null)
|
|
})
|
|
})
|
|
|
|
describe('client TTS autoplay call sites', () => {
|
|
it('catches fire-and-forget custom TTS autoplay promises', () => {
|
|
const messageItem = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
|
|
const groupMessageItem = readFileSync('packages/client/src/components/hermes/group-chat/GroupMessageItem.vue', 'utf8')
|
|
|
|
expect(messageItem).toContain('function handleAutoplayTtsError')
|
|
expect(messageItem).toContain('void speech.openaiPlay')
|
|
expect(messageItem).toContain('void speech.mimoPlay')
|
|
expect(messageItem).toContain('.catch(handleAutoplayTtsError)')
|
|
|
|
expect(groupMessageItem).toContain('function handleAutoplayTtsError')
|
|
expect(groupMessageItem).toContain('void speech.openaiPlay')
|
|
expect(groupMessageItem).toContain('void speech.mimoPlay')
|
|
expect(groupMessageItem).toContain('.catch(handleAutoplayTtsError)')
|
|
})
|
|
|
|
it('does not require a local MiMo API key before using server-stored TTS credentials', () => {
|
|
const messageItem = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
|
|
const groupMessageItem = readFileSync('packages/client/src/components/hermes/group-chat/GroupMessageItem.vue', 'utf8')
|
|
|
|
expect(messageItem).not.toContain('MiMo TTS API Key 为空')
|
|
expect(groupMessageItem).not.toContain('if (!voiceSettings.mimoApiKey.value) return')
|
|
expect(messageItem).toContain('apiKey: apiKey || undefined')
|
|
expect(groupMessageItem).toContain('apiKey: apiKey || undefined')
|
|
})
|
|
})
|