4e3feb8bbb
Desktop "Read replies aloud" / voice conversation, TUI and CLI /voice tts now hold a lease on the TTS engine. Acquiring pre-loads the configured provider (piper/kittentts model into the same LRU slot synthesis reads; lazily-installed cloud SDKs), so the first spoken reply no longer pays the model load as dead air. Releasing the last lease across surfaces unloads resident local models. - tools/tts_tool.py: warm_tts_provider / release_tts_provider / acquire_tts_lease / release_tts_lease over a _LOCAL_TTS_MODEL_CACHES registry; piper/kittentts loaders extracted so warm-up and synthesis share one resolution path. - web_server: POST /api/audio/tts-lease (profile-scoped, off-loop, failures reported in body never as HTTP errors). - tui_gateway voice.toggle + cli.py /voice tts|on|off wire the lease. - desktop: lib/tts-lease.ts (dedupe, per-lease serialization, latest intent wins) driven from useComposerVoice; setTtsLease API client. - docs: features/tts.md section. Live (real piper, isolated HERMES_HOME): first synthesis 988ms cold → 92ms after the toggle warmed the engine; release drops the model.
269 lines
9.2 KiB
TypeScript
269 lines
9.2 KiB
TypeScript
import type {
|
|
ActionResponse,
|
|
ActionStatusResponse,
|
|
AudioSpeakResponse,
|
|
AudioTranscriptionResponse,
|
|
AudioTtsLeaseResponse,
|
|
BackendUpdateCheckResponse,
|
|
CuratorStatusResponse,
|
|
DebugShareResponse,
|
|
ElevenLabsVoicesResponse,
|
|
MemoryProviderConfig,
|
|
MemoryProviderOAuthStatus,
|
|
MemoryStatusResponse
|
|
} from '@/types/hermes'
|
|
|
|
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
|
|
|
export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000
|
|
export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000
|
|
const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35
|
|
|
|
export function audioSpeakRequestTimeoutMs(text: string): number {
|
|
const estimated = Math.max(
|
|
AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,
|
|
Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR)
|
|
)
|
|
|
|
return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated)
|
|
}
|
|
|
|
export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000
|
|
export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000
|
|
// The transcribe payload is the base64 audio data URL itself, so its string
|
|
// length tracks clip size. ~0.1ms/char keeps short clips at the floor while
|
|
// letting multi-minute recordings scale toward the cap (a base64 char is
|
|
// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of
|
|
// timeout per 1s of audio before the cap clamps it).
|
|
const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1
|
|
|
|
export function audioTranscribeRequestTimeoutMs(dataUrl: string): number {
|
|
const estimated = Math.max(
|
|
AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
|
|
Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR)
|
|
)
|
|
|
|
return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated)
|
|
}
|
|
|
|
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
|
|
export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise<MemoryProviderConfig> {
|
|
return hermesApi<MemoryProviderConfig>({
|
|
...profileScoped(profile),
|
|
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
|
|
})
|
|
}
|
|
|
|
export function saveMemoryProviderConfig(
|
|
provider: string,
|
|
values: Record<string, string>,
|
|
profile?: null | string
|
|
): Promise<{ ok: boolean }> {
|
|
return hermesApi<{ ok: boolean }>({
|
|
...profileScoped(profile),
|
|
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
|
|
method: 'PUT',
|
|
body: { values }
|
|
})
|
|
}
|
|
|
|
// Memory-provider OAuth connect (provider-keyed; 404s for providers without an
|
|
// OAuth flow). Profile-scoped: the grant lands in the active profile's config.
|
|
export function startMemoryProviderOAuth(
|
|
provider: string,
|
|
profile?: null | string
|
|
): Promise<MemoryProviderOAuthStatus> {
|
|
return hermesApi<MemoryProviderOAuthStatus>({
|
|
...profileScoped(profile),
|
|
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
|
|
method: 'POST'
|
|
})
|
|
}
|
|
|
|
export function getMemoryProviderOAuthStatus(
|
|
provider: string,
|
|
profile?: null | string
|
|
): Promise<MemoryProviderOAuthStatus> {
|
|
return hermesApi<MemoryProviderOAuthStatus>({
|
|
...profileScoped(profile),
|
|
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
|
|
return hermesApi<MemoryStatusResponse>({
|
|
...profileScoped(),
|
|
path: '/api/memory'
|
|
})
|
|
}
|
|
|
|
export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> {
|
|
return hermesApi<{ ok: boolean; deleted: string[] }>({
|
|
...profileScoped(),
|
|
path: '/api/memory/reset',
|
|
method: 'POST',
|
|
body: { target }
|
|
})
|
|
}
|
|
|
|
export function getCuratorStatus(): Promise<CuratorStatusResponse> {
|
|
return hermesApi<CuratorStatusResponse>({
|
|
...profileScoped(),
|
|
path: '/api/curator'
|
|
})
|
|
}
|
|
|
|
export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> {
|
|
return hermesApi<{ ok: boolean; paused: boolean }>({
|
|
...profileScoped(),
|
|
path: '/api/curator/paused',
|
|
method: 'PUT',
|
|
body: { paused }
|
|
})
|
|
}
|
|
|
|
export function runCurator(): Promise<ActionResponse> {
|
|
return hermesApi<ActionResponse>({
|
|
...profileScoped(),
|
|
path: '/api/curator/run',
|
|
method: 'POST',
|
|
body: {}
|
|
})
|
|
}
|
|
|
|
export function restartGateway(): Promise<ActionResponse> {
|
|
return hermesApi<ActionResponse>({
|
|
...profileScoped(),
|
|
path: '/api/gateway/restart',
|
|
method: 'POST'
|
|
})
|
|
}
|
|
|
|
export function updateHermes(): Promise<ActionResponse> {
|
|
return hermesApi<ActionResponse>({
|
|
...profileScoped(),
|
|
path: '/api/hermes/update',
|
|
method: 'POST'
|
|
})
|
|
}
|
|
|
|
/** Query the connected backend's own update state. In remote mode this is the
|
|
* authoritative source for the backend's behind-count + "what's changed",
|
|
* distinct from the Electron client clone's git state. */
|
|
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
|
|
return hermesApi<BackendUpdateCheckResponse>({
|
|
...profileScoped(),
|
|
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
|
|
})
|
|
}
|
|
|
|
export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise<ActionStatusResponse> {
|
|
return window.hermesDesktop.api<ActionStatusResponse>({
|
|
...capabilityScoped(profile),
|
|
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
|
})
|
|
}
|
|
|
|
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
|
|
return hermesApi<AudioTranscriptionResponse>({
|
|
path: '/api/audio/transcribe',
|
|
method: 'POST',
|
|
...profileScoped(),
|
|
body: {
|
|
data_url: dataUrl,
|
|
mime_type: mimeType
|
|
},
|
|
// Transcription blocks until provider STT, file handling, and response
|
|
// encoding finish. Remote providers and long clips regularly exceed the
|
|
// default 15s Electron backend timeout.
|
|
timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl)
|
|
})
|
|
}
|
|
|
|
export function speakText(text: string): Promise<AudioSpeakResponse> {
|
|
return hermesApi<AudioSpeakResponse>({
|
|
...profileScoped(),
|
|
path: '/api/audio/speak',
|
|
method: 'POST',
|
|
body: { text },
|
|
// TTS blocks until provider synthesis, file read, and base64 encoding
|
|
// finish. Remote providers and large messages regularly exceed the
|
|
// default 15s Electron backend timeout.
|
|
timeoutMs: audioSpeakRequestTimeoutMs(text)
|
|
})
|
|
}
|
|
|
|
// Acquiring a lease pre-loads the configured TTS engine. For local engines
|
|
// that is a model load and, on a fresh install, a voice download — well past
|
|
// the default 15s Electron backend timeout.
|
|
export const AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS = 180_000
|
|
|
|
/**
|
|
* Tell the backend a speech-output toggle flipped so it can warm the TTS engine
|
|
* (`active: true`) or release it once no surface needs it (`active: false`).
|
|
* `lease` names the toggle — `desktop:read-aloud`, `desktop:conversation`.
|
|
*/
|
|
export function setTtsLease(lease: string, active: boolean): Promise<AudioTtsLeaseResponse> {
|
|
return hermesApi<AudioTtsLeaseResponse>({
|
|
...profileScoped(),
|
|
path: '/api/audio/tts-lease',
|
|
method: 'POST',
|
|
body: { active, lease },
|
|
timeoutMs: AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS
|
|
})
|
|
}
|
|
|
|
export function getElevenLabsVoices(profile?: null | string): Promise<ElevenLabsVoicesResponse> {
|
|
return hermesApi<ElevenLabsVoicesResponse>({
|
|
path: '/api/audio/elevenlabs/voices',
|
|
...profileScoped(profile)
|
|
})
|
|
}
|
|
|
|
/** `gh` CLI presence + auth state, for the composer's GitHub skill pill
|
|
* (GitHub is deliberately not an MCP — the github/* skills are the
|
|
* integration). Backend caches for 5 minutes; `refresh` bypasses. */
|
|
export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> {
|
|
return hermesApi<{ available: boolean; authenticated: boolean }>({
|
|
...profileScoped(),
|
|
path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}`
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Maintenance operations (parity with `hermes doctor` / `hermes security
|
|
// audit` / `hermes backup` / `hermes debug share` and the dashboard System
|
|
// page). All except debug share are spawn-based background actions tailed via
|
|
// getActionStatus().
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function runDoctor(): Promise<ActionResponse> {
|
|
return hermesApi<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
|
|
}
|
|
|
|
export function runSecurityAudit(): Promise<ActionResponse> {
|
|
return hermesApi<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
|
|
}
|
|
|
|
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
|
|
return hermesApi<ActionResponse & { archive?: string }>({
|
|
path: '/api/ops/backup',
|
|
method: 'POST',
|
|
body: {}
|
|
})
|
|
}
|
|
|
|
export function runDebugShare(): Promise<DebugShareResponse> {
|
|
return hermesApi<DebugShareResponse>({
|
|
path: '/api/ops/debug-share',
|
|
method: 'POST',
|
|
body: {},
|
|
// Synchronous upload of report + logs to the paste service.
|
|
timeoutMs: 120_000
|
|
})
|
|
}
|