Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
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')
|
|
})
|
|
})
|