initialize Electron desktop app and remove unused files
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
// @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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user