mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
feat(voice): "Say <stop-phrase> to end the voice chat" notice on voice-mode start (CLI/TUI/desktop, i18n)
One owner for the wording: voice_stop_hint() in tools/voice_mode.py — sources the phrase from voice.stop_phrases (first entry) so a custom phrase renders correctly, and returns "" when the feature is disabled (stop_phrases: []) so no surface shows a hint. - CLI: printed in /voice on output (style-matched dim notice). - TUI: voice.toggle action=on now carries stop_hint; the Ink client renders it in the "Voice mode enabled" block (older gateways omit the field — no hint, no crash). - Desktop: the renderer voice loop never touches tools/voice_mode.py, so the phrase is read from config (voice.stop_phrases → $voiceStopPhrase store, seeded in use-hermes-config) and shown as an info toast when a voice conversation starts. i18n: en/ja/zh/zh-hant/ar.
This commit is contained in:
parent
9e5f1b619c
commit
6fdfdc1597
18 changed files with 201 additions and 3 deletions
|
|
@ -7,8 +7,8 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $autoSpeakReplies, $voiceStopPhrase, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { resumeWakeAfterVoice } from '@/store/wake-word'
|
||||
|
||||
import type { ComposerTarget } from '../focus'
|
||||
|
|
@ -208,6 +208,26 @@ export function useComposerVoice({
|
|||
}
|
||||
}, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive])
|
||||
|
||||
// 'Say "stop" to end the voice chat.' notice when the conversation starts.
|
||||
// Phrase comes from voice.stop_phrases (first entry) so a custom phrase
|
||||
// renders correctly; a null phrase (stop_phrases: []) shows no notice.
|
||||
useEffect(() => {
|
||||
if (!voiceConversationActive) {
|
||||
return
|
||||
}
|
||||
|
||||
const phrase = $voiceStopPhrase.get()
|
||||
|
||||
if (phrase) {
|
||||
notify({
|
||||
id: 'voice-stop-hint',
|
||||
kind: 'info',
|
||||
icon: 'mic',
|
||||
message: t.notifications.voice.sayStopToEnd(phrase)
|
||||
})
|
||||
}
|
||||
}, [t, voiceConversationActive])
|
||||
|
||||
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
|
||||
|
||||
// Explicit start/end for the on-screen conversation controls (the hotkey uses
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
setDefaultReasoningEffort,
|
||||
setIntroPersonality
|
||||
} from '@/store/session'
|
||||
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
|
||||
import { applyAutoSpeakFromConfig, applyVoiceStopPhraseFromConfig } from '@/store/voice-prefs'
|
||||
|
||||
const DEFAULT_VOICE_SECONDS = 120
|
||||
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
|
||||
|
|
@ -105,6 +105,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
|
|||
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
applyAutoSpeakFromConfig(config)
|
||||
applyVoiceStopPhraseFromConfig(config)
|
||||
} catch {
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export const ar = defineLocale({
|
|||
noSpeechDetected: 'لم يتم اكتشاف كلام',
|
||||
playbackFailed: 'فشل تشغيل الصوت',
|
||||
recordingFailed: 'فشل التسجيل',
|
||||
sayStopToEnd: phrase => `قل "${phrase}" لإنهاء المحادثة الصوتية.`,
|
||||
transcriptionFailed: 'فشل التفريغ النصي',
|
||||
transcriptionUnavailable: 'التفريغ النصي غير متاح.',
|
||||
tryRecordingAgain: 'حاول التسجيل مرة أخرى.',
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export const en: Translations = {
|
|||
noSpeechDetected: 'No speech detected',
|
||||
playbackFailed: 'Voice playback failed',
|
||||
recordingFailed: 'Voice recording failed',
|
||||
sayStopToEnd: phrase => `Say "${phrase}" to end the voice chat.`,
|
||||
transcriptionFailed: 'Voice transcription failed',
|
||||
transcriptionUnavailable: 'Voice transcription is not available yet.',
|
||||
tryRecordingAgain: 'Try recording again.',
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export const ja = defineLocale({
|
|||
noSpeechDetected: '音声が検出されませんでした',
|
||||
playbackFailed: '音声再生に失敗しました',
|
||||
recordingFailed: '音声録音に失敗しました',
|
||||
sayStopToEnd: phrase => `「${phrase}」と言うと音声チャットを終了できます。`,
|
||||
transcriptionFailed: '音声文字起こしに失敗しました',
|
||||
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
|
||||
tryRecordingAgain: 'もう一度録音してください。',
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export interface Translations {
|
|||
noSpeechDetected: string
|
||||
playbackFailed: string
|
||||
recordingFailed: string
|
||||
sayStopToEnd: (phrase: string) => string
|
||||
transcriptionFailed: string
|
||||
transcriptionUnavailable: string
|
||||
tryRecordingAgain: string
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export const zhHant = defineLocale({
|
|||
noSpeechDetected: '未偵測到語音',
|
||||
playbackFailed: '語音播放失敗',
|
||||
recordingFailed: '語音錄製失敗',
|
||||
sayStopToEnd: phrase => `說「${phrase}」即可結束語音對話。`,
|
||||
transcriptionFailed: '語音轉寫失敗',
|
||||
transcriptionUnavailable: '語音轉寫暫不可用。',
|
||||
tryRecordingAgain: '請再錄製一次。',
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export const zh: Translations = {
|
|||
noSpeechDetected: '没有检测到语音',
|
||||
playbackFailed: '语音播放失败',
|
||||
recordingFailed: '语音录制失败',
|
||||
sayStopToEnd: phrase => `说“${phrase}”即可结束语音对话。`,
|
||||
transcriptionFailed: '语音转写失败',
|
||||
transcriptionUnavailable: '语音转写暂不可用。',
|
||||
tryRecordingAgain: '请再录一次。',
|
||||
|
|
|
|||
38
apps/desktop/src/store/voice-prefs.test.ts
Normal file
38
apps/desktop/src/store/voice-prefs.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getHermesConfigRecord: vi.fn(async () => ({})),
|
||||
saveHermesConfig: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
import { $voiceStopPhrase, applyVoiceStopPhraseFromConfig } from './voice-prefs'
|
||||
|
||||
describe('applyVoiceStopPhraseFromConfig', () => {
|
||||
it('defaults to "stop" when the key is absent (backend default applies)', () => {
|
||||
applyVoiceStopPhraseFromConfig({ voice: {} })
|
||||
expect($voiceStopPhrase.get()).toBe('stop')
|
||||
|
||||
applyVoiceStopPhraseFromConfig(null)
|
||||
expect($voiceStopPhrase.get()).toBe('stop')
|
||||
})
|
||||
|
||||
it('uses the first configured phrase so a custom phrase renders correctly', () => {
|
||||
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: ['goodbye hermes', 'stop'] } })
|
||||
expect($voiceStopPhrase.get()).toBe('goodbye hermes')
|
||||
})
|
||||
|
||||
it('coerces a bare string like the backend does', () => {
|
||||
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: 'halt' } })
|
||||
expect($voiceStopPhrase.get()).toBe('halt')
|
||||
})
|
||||
|
||||
it('null phrase when stop phrases are disabled — no notice is shown', () => {
|
||||
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: [] } })
|
||||
expect($voiceStopPhrase.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('malformed entries are skipped; all-blank list disables', () => {
|
||||
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: [' ', ''] } })
|
||||
expect($voiceStopPhrase.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -12,6 +12,31 @@ export function applyAutoSpeakFromConfig(config: { voice?: { auto_tts?: unknown
|
|||
$autoSpeakReplies.set(Boolean(config?.voice?.auto_tts))
|
||||
}
|
||||
|
||||
// First configured `voice.stop_phrases` entry — drives the "Say "stop" to end
|
||||
// the voice chat" notice shown when a voice conversation starts. `null` means
|
||||
// the user disabled stop phrases (`stop_phrases: []`), so no notice is shown.
|
||||
// Defaults to "stop" (the backend default) before config loads.
|
||||
export const $voiceStopPhrase = atom<string | null>('stop')
|
||||
|
||||
/** Seed the stop-phrase atom from a loaded config payload (mount / refresh). */
|
||||
export function applyVoiceStopPhraseFromConfig(
|
||||
config: { voice?: { stop_phrases?: unknown } | null } | null | undefined
|
||||
) {
|
||||
const raw = config?.voice?.stop_phrases
|
||||
|
||||
if (raw === undefined) {
|
||||
// Key absent — backend default applies.
|
||||
$voiceStopPhrase.set('stop')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []
|
||||
const first = list.map(entry => String(entry).trim()).find(entry => entry.length > 0)
|
||||
|
||||
$voiceStopPhrase.set(first ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the preference and persist it. Optimistic — the atom updates instantly and
|
||||
* reverts if the config write fails. Read-modify-writes the whole record (the
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ export interface HermesConfig {
|
|||
voice?: {
|
||||
max_recording_seconds?: number
|
||||
auto_tts?: boolean
|
||||
stop_phrases?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
9
cli.py
9
cli.py
|
|
@ -12283,6 +12283,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
_ptt_display = self._voice_record_key_label()
|
||||
_cprint(f"\n{_ACCENT}Voice mode enabled{tts_status}{_RST}")
|
||||
_cprint(f" {_DIM}{_ptt_display} to start/stop recording{_RST}")
|
||||
# Spoken-stop hint sourced from voice.stop_phrases (first entry); the
|
||||
# helper returns "" when stop phrases are disabled — show no hint then.
|
||||
try:
|
||||
from tools.voice_mode import voice_stop_hint
|
||||
_stop_hint = voice_stop_hint()
|
||||
except Exception:
|
||||
_stop_hint = ""
|
||||
if _stop_hint:
|
||||
_cprint(f" {_DIM}{_stop_hint}{_RST}")
|
||||
_cprint(f" {_DIM}/voice tts to toggle speech output{_RST}")
|
||||
_cprint(f" {_DIM}/voice off to disable voice mode{_RST}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1188,6 +1188,47 @@ def test_voice_toggle_returns_configured_record_key(monkeypatch):
|
|||
assert status_resp["result"]["record_key"] == "ctrl+o"
|
||||
|
||||
|
||||
def test_voice_toggle_on_carries_stop_hint(monkeypatch):
|
||||
"""voice.toggle action=on returns the spoken-stop hint for clients to
|
||||
render — sourced from voice.stop_phrases so a custom phrase shows
|
||||
correctly, and empty when the feature is disabled (stop_phrases: [])."""
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}})
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"tools.voice_mode",
|
||||
types.SimpleNamespace(
|
||||
check_voice_requirements=lambda: {"available": True, "details": ""},
|
||||
voice_stop_hint=lambda: 'Say "halt" to end the voice chat.',
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_VOICE", "0")
|
||||
|
||||
on_resp = server.dispatch(
|
||||
{"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
|
||||
)
|
||||
assert on_resp["result"]["stop_hint"] == 'Say "halt" to end the voice chat.'
|
||||
|
||||
# Disabled stop phrases → empty hint, clients show nothing.
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"tools.voice_mode",
|
||||
types.SimpleNamespace(
|
||||
check_voice_requirements=lambda: {"available": True, "details": ""},
|
||||
voice_stop_hint=lambda: "",
|
||||
),
|
||||
)
|
||||
on_resp = server.dispatch(
|
||||
{"id": "voice-on2", "method": "voice.toggle", "params": {"action": "on"}}
|
||||
)
|
||||
assert on_resp["result"]["stop_hint"] == ""
|
||||
|
||||
# off carries no hint text (mode is ending).
|
||||
off_resp = server.dispatch(
|
||||
{"id": "voice-off", "method": "voice.toggle", "params": {"action": "off"}}
|
||||
)
|
||||
assert off_resp["result"]["stop_hint"] == ""
|
||||
|
||||
|
||||
def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
|
||||
"""Round-3 Copilot review regression on #19835.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,29 @@ from tools.voice_mode import (
|
|||
DEFAULT_VOICE_STOP_PHRASES,
|
||||
_load_voice_stop_phrases,
|
||||
is_voice_stop_phrase,
|
||||
voice_stop_hint,
|
||||
)
|
||||
|
||||
|
||||
class TestVoiceStopHint:
|
||||
"""The 'Say "stop" to end the voice chat.' hint shown on voice-mode start."""
|
||||
|
||||
def test_default_phrase(self):
|
||||
with patch("tools.voice_mode._load_voice_stop_phrases", return_value=("stop",)):
|
||||
assert voice_stop_hint() == 'Say "stop" to end the voice chat.'
|
||||
|
||||
def test_custom_phrase_uses_first_entry(self):
|
||||
with patch(
|
||||
"tools.voice_mode._load_voice_stop_phrases",
|
||||
return_value=("goodbye hermes", "stop"),
|
||||
):
|
||||
assert voice_stop_hint() == 'Say "goodbye hermes" to end the voice chat.'
|
||||
|
||||
def test_disabled_phrases_show_no_hint(self):
|
||||
with patch("tools.voice_mode._load_voice_stop_phrases", return_value=()):
|
||||
assert voice_stop_hint() == ""
|
||||
|
||||
|
||||
class TestIsVoiceStopPhrase:
|
||||
@pytest.mark.parametrize("utterance", [
|
||||
"stop", "Stop", "STOP", "stop.", "Stop!", " stop ", '"Stop."', "stop?",
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,21 @@ def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None)
|
|||
return cleaned in stop_phrases
|
||||
|
||||
|
||||
def voice_stop_hint() -> str:
|
||||
"""One-line 'Say "stop" to end the voice chat.' hint for voice-mode start.
|
||||
|
||||
Sources the phrase from ``voice.stop_phrases`` (first entry) so a custom
|
||||
phrase renders correctly; returns "" when stop phrases are disabled
|
||||
(``stop_phrases: []``) so surfaces show no hint at all. Every surface
|
||||
that announces voice-mode start (CLI /voice on, TUI, desktop) uses this
|
||||
one owner instead of hardcoding the wording.
|
||||
"""
|
||||
phrases = _load_voice_stop_phrases()
|
||||
if not phrases:
|
||||
return ""
|
||||
return f'Say "{phrases[0]}" to end the voice chat.'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STT dispatch
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -18287,6 +18287,18 @@ def _(rid, params: dict) -> dict:
|
|||
# persisted stale toggle.
|
||||
os.environ["HERMES_VOICE"] = "1" if enabled else "0"
|
||||
|
||||
stop_hint = ""
|
||||
if enabled:
|
||||
# Spoken-stop hint for the client to render on voice-mode start.
|
||||
# Sourced from voice.stop_phrases (custom phrases render
|
||||
# correctly); empty when the feature is disabled.
|
||||
try:
|
||||
from tools.voice_mode import voice_stop_hint
|
||||
|
||||
stop_hint = voice_stop_hint()
|
||||
except Exception:
|
||||
stop_hint = ""
|
||||
|
||||
if not enabled:
|
||||
# Disabling the mode must tear the continuous loop down; the
|
||||
# loop holds the microphone and would otherwise keep running.
|
||||
|
|
@ -18310,6 +18322,7 @@ def _(rid, params: dict) -> dict:
|
|||
"enabled": enabled,
|
||||
"record_key": _voice_record_key(),
|
||||
"tts": _voice_tts_enabled(),
|
||||
"stop_hint": stop_hint,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -380,6 +380,14 @@ export const sessionCommands: SlashCommand[] = [
|
|||
const tts = r.tts ? ' (TTS enabled)' : ''
|
||||
ctx.transcript.sys(`Voice mode enabled${tts}`)
|
||||
ctx.transcript.sys(` ${recordKeyLabel} to start/stop recording`)
|
||||
|
||||
// Spoken-stop hint — backend-sourced from voice.stop_phrases so a
|
||||
// custom phrase renders correctly; absent/empty means the feature
|
||||
// is disabled (stop_phrases: []) and no hint is shown.
|
||||
if (r.stop_hint) {
|
||||
ctx.transcript.sys(` ${r.stop_hint}`)
|
||||
}
|
||||
|
||||
ctx.transcript.sys(' /voice tts to toggle speech output')
|
||||
ctx.transcript.sys(' /voice off to disable voice mode')
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ export interface VoiceToggleResponse {
|
|||
details?: string
|
||||
enabled?: boolean
|
||||
record_key?: string
|
||||
stop_hint?: string
|
||||
stt_available?: boolean
|
||||
tts?: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue