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.
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { nextTick } from 'vue'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('@/router', () => ({
|
|
default: {
|
|
currentRoute: { value: { name: 'hermes.chat' } },
|
|
replace: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
import router from '@/router'
|
|
import { hasApiKey } from '../../packages/client/src/api/client'
|
|
import { clearSttSecret, deleteSttBaseUrlPreset, deleteSttProvider, fetchSttSettings, saveActiveSttProvider, saveSttSettings } from '../../packages/client/src/api/hermes/stt-settings'
|
|
import { transcribeSpeech } from '../../packages/client/src/api/hermes/stt'
|
|
|
|
const mockFetch = vi.fn()
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
function getHeader(headers: RequestInit['headers'] | undefined, name: string): string | undefined {
|
|
if (!headers) return undefined
|
|
if (headers instanceof Headers) return headers.get(name) ?? undefined
|
|
if (Array.isArray(headers)) {
|
|
const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase())
|
|
return match?.[1]
|
|
}
|
|
|
|
const match = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
|
return typeof match?.[1] === 'string' ? match[1] : undefined
|
|
}
|
|
|
|
describe('stt api wrappers', () => {
|
|
beforeEach(() => {
|
|
mockFetch.mockReset()
|
|
vi.clearAllMocks()
|
|
localStorage.clear()
|
|
localStorage.setItem('hermes_server_url', 'https://hermes.example')
|
|
localStorage.setItem('hermes_api_key', 'jwt-token')
|
|
})
|
|
|
|
it('fetches provider settings through the shared client request flow and normalizes settings arrays to providers', async () => {
|
|
localStorage.setItem('hermes_active_profile_name', 'research')
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({
|
|
activeProvider: 'custom',
|
|
settings: [
|
|
{
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe', language: 'en' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
const result = await fetchSttSettings()
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'Content-Type': 'application/json',
|
|
'X-Hermes-Profile': 'research',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
activeProvider: 'custom',
|
|
providers: [
|
|
{
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe', language: 'en' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it('uses shared 401 handling for settings requests', async () => {
|
|
localStorage.setItem('hermes_active_profile_name', 'research')
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
|
|
await expect(fetchSttSettings()).rejects.toThrow('Unauthorized')
|
|
|
|
expect(hasApiKey()).toBe(false)
|
|
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'X-Hermes-Profile': 'research',
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('saves the active STT provider without a provider settings row', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ activeProvider: 'browser' }),
|
|
})
|
|
|
|
const result = await saveActiveSttProvider('browser')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/active',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ provider: 'browser' }),
|
|
}),
|
|
)
|
|
expect(result).toBe('browser')
|
|
})
|
|
|
|
it('saves masked stt settings and unwraps the response setting', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
setting: {
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 2,
|
|
},
|
|
}),
|
|
})
|
|
|
|
const result = await saveSttSettings('openai', {
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: 'new-secret' },
|
|
})
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/openai',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: 'new-secret' },
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 2,
|
|
})
|
|
})
|
|
|
|
it('clears stored api keys via the delete secret endpoint', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
setting: {
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: {},
|
|
updatedAt: 3,
|
|
},
|
|
}),
|
|
})
|
|
|
|
const result = await clearSttSecret('openai', 'apiKey')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/openai/secret/apiKey',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: {},
|
|
updatedAt: 3,
|
|
})
|
|
})
|
|
|
|
it('deletes stored provider settings via the provider endpoint', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
deleted: true,
|
|
activeProvider: 'browser',
|
|
}),
|
|
})
|
|
|
|
const result = await deleteSttProvider('openai')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/openai',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
success: true,
|
|
deleted: true,
|
|
activeProvider: 'browser',
|
|
})
|
|
})
|
|
|
|
it('deletes saved base URL presets via the provider preset endpoint', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
setting: {
|
|
provider: 'custom',
|
|
settings: {
|
|
baseUrl: 'https://stt.example.test/openai/v1',
|
|
baseUrlPresets: ['https://stt.example.test/openai/v1'],
|
|
model: 'whisper-large-v3-turbo',
|
|
},
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 4,
|
|
},
|
|
}),
|
|
})
|
|
|
|
const result = await deleteSttBaseUrlPreset('custom', 'https://api.groq.com/openai/v1')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/custom/base-url-preset?url=https%3A%2F%2Fapi.groq.com%2Fopenai%2Fv1',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
provider: 'custom',
|
|
settings: {
|
|
baseUrl: 'https://stt.example.test/openai/v1',
|
|
baseUrlPresets: ['https://stt.example.test/openai/v1'],
|
|
model: 'whisper-large-v3-turbo',
|
|
},
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 4,
|
|
})
|
|
})
|
|
|
|
it('rejects transcription requests without a provider', async () => {
|
|
await expect(transcribeSpeech({
|
|
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
|
} as any)).rejects.toThrow('STT provider is required')
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends multipart transcription requests without serializing api keys or forcing JSON content type', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
text: 'transcribed text',
|
|
provider: 'openai',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
durationMs: 42,
|
|
}),
|
|
})
|
|
|
|
const result = await transcribeSpeech({
|
|
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
|
provider: 'openai',
|
|
language: 'en',
|
|
prompt: 'keep punctuation',
|
|
baseUrl: 'http://127.0.0.1:8000/v1/audio/transcriptions',
|
|
apiKey: 'attacker-secret',
|
|
secrets: { apiKey: 'body-secret' },
|
|
headers: {
|
|
Authorization: 'Bearer attacker-secret',
|
|
'X-Api-Key': 'body-secret',
|
|
},
|
|
} as any)
|
|
|
|
expect(result).toEqual({
|
|
text: 'transcribed text',
|
|
provider: 'openai',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
durationMs: 42,
|
|
})
|
|
|
|
expect(mockFetch).toHaveBeenCalledOnce()
|
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://hermes.example/api/hermes/stt/transcribe')
|
|
expect(init.method).toBe('POST')
|
|
expect(getHeader(init.headers, 'Authorization')).toBe('Bearer jwt-token')
|
|
expect(getHeader(init.headers, 'Content-Type')).toBeUndefined()
|
|
expect(init.body).toBeInstanceOf(FormData)
|
|
|
|
const form = init.body as FormData
|
|
expect(form.get('provider')).toBe('openai')
|
|
expect(form.get('language')).toBe('en')
|
|
expect(form.get('prompt')).toBe('keep punctuation')
|
|
expect(form.get('baseUrl')).toBeNull()
|
|
expect(form.get('apiKey')).toBeNull()
|
|
expect(form.get('secrets')).toBeNull()
|
|
expect(form.get('headers')).toBeNull()
|
|
|
|
const keys = Array.from(form.keys())
|
|
expect(keys).toEqual(expect.arrayContaining(['audio', 'provider', 'language', 'prompt']))
|
|
expect(keys).not.toContain('baseUrl')
|
|
expect(keys).not.toContain('apiKey')
|
|
expect(keys).not.toContain('secrets')
|
|
expect(keys).not.toContain('headers')
|
|
|
|
const upload = form.get('audio')
|
|
expect(upload).toBeInstanceOf(File)
|
|
expect((upload as File).name).toBe('speech.webm')
|
|
expect((upload as File).type).toBe('audio/webm')
|
|
|
|
const serialized = JSON.stringify(Array.from(form.entries()).map(([key, value]) => [
|
|
key,
|
|
typeof value === 'string'
|
|
? value
|
|
: { name: value.name, type: value.type },
|
|
]))
|
|
expect(serialized).not.toContain('attacker-secret')
|
|
expect(serialized).not.toContain('body-secret')
|
|
expect(serialized).not.toContain('127.0.0.1:8000')
|
|
})
|
|
|
|
it('does not persist raw STT API keys to localStorage when composable state changes', async () => {
|
|
const { clearSttSettingsAuthState, useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
|
|
|
|
clearSttSettingsAuthState()
|
|
const settings = useSttSettings()
|
|
|
|
settings.setOpenaiApiKey('openai-secret-sentinel')
|
|
settings.setCustomApiKey('custom-secret-sentinel')
|
|
settings.setOpenaiPrompt('transcribe carefully')
|
|
settings.setCustomBaseUrl('https://transcribe.example.com/v1')
|
|
await nextTick()
|
|
|
|
const raw = localStorage.getItem('hermes-stt-settings-v1') || '{}'
|
|
expect(raw).toContain('transcribe carefully')
|
|
expect(raw).toContain('https://transcribe.example.com/v1')
|
|
expect(raw).not.toContain('openai-secret-sentinel')
|
|
expect(raw).not.toContain('custom-secret-sentinel')
|
|
})
|
|
|
|
it('uses shared 401 handling for transcription requests', async () => {
|
|
localStorage.setItem('hermes_active_profile_name', 'research')
|
|
const listener = vi.fn()
|
|
window.addEventListener('hermes-auth-notice', listener)
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
|
|
await expect(transcribeSpeech({
|
|
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
|
provider: 'openai',
|
|
})).rejects.toThrow('Unauthorized')
|
|
|
|
expect(hasApiKey()).toBe(false)
|
|
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
|
expect(listener).toHaveBeenCalledOnce()
|
|
expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'expired' })
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/transcribe',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'X-Hermes-Profile': 'research',
|
|
}),
|
|
}),
|
|
)
|
|
|
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(getHeader(init.headers, 'Content-Type')).toBeUndefined()
|
|
expect(init.body).toBeInstanceOf(FormData)
|
|
window.removeEventListener('hermes-auth-notice', listener)
|
|
})
|
|
})
|