mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* feat(voice): voice personality, picker catalog, and prompt layer foundation - voicePickerCatalog.ts: per-backend voice lists for Gemini and Qwen with resolve helpers (resolveGeminiLiveVoice, resolveQwenRealtimeVoice) - voicePersonality.ts: VoicePersonalityPreferences schema, presets, composed system prompt with identity/character/response-length layers - voicePromptLayers.ts: buildResolvedVoiceSystemPrompt, preset delivery snippets - voiceSystemPromptParam.ts: hub-side base64url decode for ?systemPrompt= - voicePickerPreferences.ts, voicePersonalitySession.ts: browser-side encode, decode, and storage helpers - useVoicePersonality: React hook for preferences persistence via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): preset delivery included when non-balanced preset selected; restore test typecheck - isDefaultVoicePersonality: add preset check so warm/calm/direct presets trigger the delivery snippet instead of being treated as default - web/tsconfig.json: remove test file exclusion from typecheck (restoring strict coverage of test code); fix resulting type error in mock declaration via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): include use_speaker_boost in ElevenLabs TTS override payload The checkbox persisted the pref but ttsDiffersFromDefault and buildElevenLabsTtsOverride both omitted it, so the setting was never sent to the agent. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * test(voice): update speaker_boost test to assert it IS included in override The previous test asserted use_speaker_boost was omitted; now it's correctly included in the TTS payload. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): authorize use_speaker_boost in ElevenLabs override schema Add use_speaker_boost to both the VoiceAgentConfig tts override type and the buildVoiceAgentConfig() platform_settings so the field is accepted by the ElevenLabs agent runtime. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): propagate full language code through composed prompt, not just zh getDefaultVoiceSystemPrompt and resolveComposedVoiceSystemPrompt were filtering language to zh-only before passing to composeVoiceAgentPrompt. Now append buildVoiceLanguageBlock(language) after composition so French, Spanish, Japanese etc. reach Gemini/Qwen sessions correctly. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): only append language block when language explicitly set Building language block unconditionally when no language is given caused getDefaultVoiceSystemPrompt() to diverge from VOICE_SYSTEM_PROMPT. Only append the block when a code is explicitly provided. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): always include language block for Gemini/Qwen in composed prompt When auto-detect is on (language=undefined), the composed prompt sent via hub proxy was losing the language auto-detect instruction because the block was only added when language was explicitly set. Now: ElevenLabs skips the block (has its own language field); Gemini/Qwen always include it — undefined produces the auto-detect block, an explicit code produces the appropriate language instruction. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run>
155 lines
5.3 KiB
TypeScript
155 lines
5.3 KiB
TypeScript
import { buildVoiceAgentConfig, VOICE_TOOLS } from '@hapi/protocol/voice'
|
|
import {
|
|
DEFAULT_ELEVENLABS_VOICE_SETTINGS,
|
|
ELEVENLABS_WEBRTC_CONTEXT_MAX_BYTES,
|
|
isDefaultVoicePersonality,
|
|
resolveComposedVoiceSystemPrompt,
|
|
resolveElevenLabsVoiceSettings,
|
|
truncateUtf8ByteLength,
|
|
type ElevenLabsVoiceSettings,
|
|
type VoicePersonalityPreferences
|
|
} from '@hapi/protocol/voice-personality'
|
|
import { loadVoicePersonalityFromStorage } from '@/hooks/useVoicePersonality'
|
|
|
|
/**
|
|
* ElevenLabs convai rejects sessions whose `overrides` payload references properties
|
|
* the agent has not explicitly authorized (see
|
|
* https://elevenlabs.io/docs/agents-platform/customization/personalization/overrides).
|
|
* The server emits a malformed error packet over the LiveKit data channel, which the
|
|
* convai-react SDK then dereferences as `event.error_type` — undefined → TypeError →
|
|
* disconnect.
|
|
*
|
|
* Defensive rule: emit each top-level override **only** when the user has explicitly
|
|
* diverged from defaults. Empty-prefs sessions must produce exactly `{ agent: { language } }`
|
|
* to match the upstream/main baseline that the convai agent permits today.
|
|
*/
|
|
|
|
function ttsDiffersFromDefault(tts: ElevenLabsVoiceSettings): boolean {
|
|
const d = DEFAULT_ELEVENLABS_VOICE_SETTINGS
|
|
return tts.stability !== d.stability
|
|
|| tts.similarity_boost !== d.similarity_boost
|
|
|| tts.style !== d.style
|
|
|| tts.speed !== d.speed
|
|
|| tts.use_speaker_boost !== d.use_speaker_boost
|
|
}
|
|
|
|
function buildElevenLabsTtsOverride(
|
|
tts: ElevenLabsVoiceSettings,
|
|
voiceId?: string
|
|
): Record<string, unknown> {
|
|
return {
|
|
stability: tts.stability,
|
|
similarity_boost: tts.similarity_boost,
|
|
style: tts.style,
|
|
speed: tts.speed,
|
|
use_speaker_boost: tts.use_speaker_boost,
|
|
...(voiceId ? { voice_id: voiceId } : {})
|
|
}
|
|
}
|
|
|
|
function buildElevenLabsAgentPromptOverride(
|
|
prefs: VoicePersonalityPreferences,
|
|
options: { language?: string }
|
|
) {
|
|
const base = buildVoiceAgentConfig().conversation_config.agent.prompt
|
|
const composed = resolveComposedVoiceSystemPrompt(prefs, {
|
|
language: options.language,
|
|
backend: 'elevenlabs'
|
|
})
|
|
return {
|
|
prompt: composed.prompt,
|
|
llm: base.llm,
|
|
temperature: base.temperature,
|
|
max_tokens: base.max_tokens,
|
|
tools: VOICE_TOOLS
|
|
}
|
|
}
|
|
|
|
/** Session context for ElevenLabs dynamicVariables only (never embed in prompt override). */
|
|
export function capElevenLabsInitialContext(initialContext?: string): string {
|
|
if (!initialContext?.trim()) return ''
|
|
return truncateUtf8ByteLength(initialContext, ELEVENLABS_WEBRTC_CONTEXT_MAX_BYTES)
|
|
}
|
|
|
|
export interface ElevenLabsSessionOverrides {
|
|
agent?: {
|
|
language?: string
|
|
prompt?: ReturnType<typeof buildElevenLabsAgentPromptOverride>
|
|
}
|
|
tts?: Record<string, unknown>
|
|
}
|
|
|
|
export function buildElevenLabsSessionOverridesFromPrefs(
|
|
prefs: VoicePersonalityPreferences,
|
|
config: {
|
|
language?: string
|
|
voiceId?: string
|
|
}
|
|
): ElevenLabsSessionOverrides {
|
|
const overrides: ElevenLabsSessionOverrides = {}
|
|
|
|
const tts = resolveElevenLabsVoiceSettings(prefs)
|
|
const customTts = ttsDiffersFromDefault(tts)
|
|
if (customTts) {
|
|
overrides.tts = buildElevenLabsTtsOverride(tts, config.voiceId)
|
|
} else if (config.voiceId) {
|
|
overrides.tts = { voice_id: config.voiceId }
|
|
}
|
|
|
|
const agent: NonNullable<ElevenLabsSessionOverrides['agent']> = {}
|
|
if (config.language) {
|
|
agent.language = config.language
|
|
}
|
|
if (!isDefaultVoicePersonality(prefs)) {
|
|
agent.prompt = buildElevenLabsAgentPromptOverride(prefs, {
|
|
language: config.language
|
|
})
|
|
}
|
|
if (Object.keys(agent).length > 0) {
|
|
overrides.agent = agent
|
|
}
|
|
|
|
return overrides
|
|
}
|
|
|
|
export function buildElevenLabsSessionOverrides(config: {
|
|
language?: string
|
|
voiceId?: string
|
|
}) {
|
|
return buildElevenLabsSessionOverridesFromPrefs(loadVoicePersonalityFromStorage(), config)
|
|
}
|
|
|
|
/** Base64url for Gemini hub proxy (?systemPrompt=). */
|
|
export function encodeVoiceSystemPromptForProxy(prompt: string): string {
|
|
const bytes = new TextEncoder().encode(prompt)
|
|
let binary = ''
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]!)
|
|
}
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
}
|
|
|
|
// base64url: 3 raw bytes → 4 encoded chars, so 12 000 encoded chars ≈ 9 000 raw bytes.
|
|
const PROXY_PROMPT_MAX_RAW_BYTES = Math.floor(12_000 * 3 / 4)
|
|
|
|
/**
|
|
* Truncate a string so that its UTF-8 byte length does not exceed the proxy
|
|
* query-param cap. Truncates on a byte boundary (safe for multi-byte chars).
|
|
*/
|
|
export function truncatePromptForProxy(prompt: string): string {
|
|
const bytes = new TextEncoder().encode(prompt)
|
|
if (bytes.length <= PROXY_PROMPT_MAX_RAW_BYTES) return prompt
|
|
return new TextDecoder().decode(bytes.slice(0, PROXY_PROMPT_MAX_RAW_BYTES))
|
|
}
|
|
|
|
export function buildResolvedVoiceSystemPrompt(options?: {
|
|
language?: string
|
|
backend?: 'elevenlabs' | 'gemini-live' | 'qwen-realtime'
|
|
}): string {
|
|
const personality = loadVoicePersonalityFromStorage()
|
|
return resolveComposedVoiceSystemPrompt(personality, {
|
|
language: options?.language,
|
|
backend: options?.backend ?? 'gemini-live'
|
|
}).prompt
|
|
}
|