mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(desktop): end a hands-free voice conversation by saying "stop"
Saying 'stop' in a voice chat did nothing — the transcript was just
submitted to the agent as a normal turn, so the conversation never ended.
The only way out was the mouse/hotkey. That's not how a hands-free voice
assistant should work.
useVoiceConversation now checks each finished utterance against a spoken
stop-command matcher BEFORE submitting: 'stop', 'stop listening', 'never
mind', 'goodbye', 'cancel', 'that's all', etc., optionally addressed
('hey hermes, stop'). A match ends the conversation (flips enabled=false,
which drives the existing end() teardown — mic close, playback stop, wake
re-arm) instead of sending a turn.
Deliberately conservative: only a WHOLE-utterance stop phrase matches, so
substantive requests that merely contain 'stop' ('stop the docker
container', 'how do I stop a process') still go through.
- apps/desktop/src/lib/voice-stop-word.ts: isVoiceStopCommand() matcher
- use-voice-conversation.ts: onStopWord option + intercept before submit
- use-composer-voice.ts: wire onStopWord -> end the conversation
- 6 matcher tests (bare/multi-word/addressed stop; substantive requests
with 'stop' pass through; bare address words don't match)
- docs: note the spoken-stop behavior
This commit is contained in:
parent
f106e0ebc2
commit
09c62d5da3
5 changed files with 188 additions and 0 deletions
|
|
@ -121,6 +121,11 @@ export function useComposerVoice({
|
|||
consumePendingResponse,
|
||||
enabled: voiceConversationActive,
|
||||
onFatalError: () => setVoiceConversationActive(false),
|
||||
// A spoken stop command ("stop", "never mind", "goodbye", …) ends the
|
||||
// hands-free conversation. Flipping the flag is the authoritative off
|
||||
// switch — the enabled=false prop + effect below drive conversation.end()
|
||||
// teardown (mic close, wake re-arm).
|
||||
onStopWord: () => setVoiceConversationActive(false),
|
||||
onSubmit: submitVoiceTurn,
|
||||
onTranscribeAudio,
|
||||
pendingResponse: pendingTurnResponse
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
startSpeechStream,
|
||||
stopVoicePlayback
|
||||
} from '@/lib/voice-playback'
|
||||
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useMicRecorder } from './use-mic-recorder'
|
||||
|
|
@ -25,6 +26,7 @@ interface VoiceConversationOptions {
|
|||
busy: boolean
|
||||
enabled: boolean
|
||||
onFatalError?: () => void
|
||||
onStopWord?: () => void
|
||||
onSubmit: (text: string) => Promise<void> | void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
pendingResponse: () => PendingVoiceResponse | null
|
||||
|
|
@ -35,6 +37,7 @@ export function useVoiceConversation({
|
|||
busy,
|
||||
enabled,
|
||||
onFatalError,
|
||||
onStopWord,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
pendingResponse,
|
||||
|
|
@ -59,6 +62,12 @@ export function useVoiceConversation({
|
|||
const busyRef = useRef(busy)
|
||||
const statusRef = useRef<ConversationStatus>('idle')
|
||||
const wasEnabledRef = useRef(enabled)
|
||||
const onStopWordRef = useRef(onStopWord)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
onStopWordRef.current = onStopWord
|
||||
}, [onStopWord])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
|
|
@ -132,6 +141,18 @@ export function useVoiceConversation({
|
|||
return
|
||||
}
|
||||
|
||||
// A spoken "stop" (or "never mind", "goodbye", …) ends the
|
||||
// conversation instead of being submitted as a turn. Only whole-
|
||||
// utterance stop commands match, so "stop the container" still goes
|
||||
// through as a real request.
|
||||
if (isVoiceStopCommand(transcript)) {
|
||||
dropSpeechSession()
|
||||
setStatus('idle')
|
||||
onStopWordRef.current?.()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
dropSpeechSession()
|
||||
await onSubmit(transcript)
|
||||
|
|
|
|||
62
apps/desktop/src/lib/voice-stop-word.test.ts
Normal file
62
apps/desktop/src/lib/voice-stop-word.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isVoiceStopCommand } from './voice-stop-word'
|
||||
|
||||
describe('isVoiceStopCommand', () => {
|
||||
it('matches bare stop commands', () => {
|
||||
for (const phrase of ['stop', 'Stop', 'STOP', 'stop.', 'stop!', ' stop ', 'stop…']) {
|
||||
expect(isVoiceStopCommand(phrase)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches multi-word stop phrases', () => {
|
||||
for (const phrase of [
|
||||
'stop listening',
|
||||
'stop it',
|
||||
'please stop',
|
||||
'stop please',
|
||||
"that's all",
|
||||
'that is all',
|
||||
'never mind',
|
||||
'nevermind',
|
||||
'end conversation',
|
||||
'end the conversation',
|
||||
'goodbye',
|
||||
'bye',
|
||||
'cancel'
|
||||
]) {
|
||||
expect(isVoiceStopCommand(phrase)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches stop commands addressed to Hermes', () => {
|
||||
for (const phrase of ['hermes stop', 'hey hermes stop', 'hey hermes, stop', 'ok stop', 'okay stop']) {
|
||||
expect(isVoiceStopCommand(phrase)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('does NOT match substantive requests that merely contain "stop"', () => {
|
||||
for (const phrase of [
|
||||
'stop the docker container',
|
||||
'how do I stop a running process',
|
||||
'can you stop the deployment',
|
||||
'stop the music and play something else',
|
||||
"don't stop now",
|
||||
'the bus stop is closed'
|
||||
]) {
|
||||
expect(isVoiceStopCommand(phrase)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not match bare address words or empty input', () => {
|
||||
for (const phrase of ['', ' ', 'hermes', 'hey hermes', 'ok', 'okay', 'hey']) {
|
||||
expect(isVoiceStopCommand(phrase)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not match unrelated short utterances', () => {
|
||||
for (const phrase of ['hello', 'yes', 'what time is it', 'thanks']) {
|
||||
expect(isVoiceStopCommand(phrase)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
94
apps/desktop/src/lib/voice-stop-word.ts
Normal file
94
apps/desktop/src/lib/voice-stop-word.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Spoken stop-word detection for the voice conversation loop.
|
||||
//
|
||||
// When someone is in a hands-free "Hey Hermes" voice chat, the natural way to
|
||||
// end it is to SAY "stop" — not reach for the mouse. Without this, a spoken
|
||||
// "stop" is just transcribed and sent to the agent as a normal turn, so the
|
||||
// conversation never ends (the reported bug). This matcher recognises a short
|
||||
// utterance whose entire content is a stop command and ends the conversation
|
||||
// instead of submitting it.
|
||||
//
|
||||
// Deliberately conservative: it only fires when the WHOLE utterance is a stop
|
||||
// phrase (optionally addressed to Hermes), so a real turn that merely contains
|
||||
// the word "stop" — e.g. "stop the docker container" or "how do I stop a
|
||||
// running process" — is never swallowed.
|
||||
|
||||
// Canonical stop commands. Kept short and unambiguous; each must be the entire
|
||||
// spoken utterance to match.
|
||||
const STOP_PHRASES: readonly string[] = [
|
||||
'stop',
|
||||
'stop listening',
|
||||
'stop it',
|
||||
'stop please',
|
||||
'please stop',
|
||||
'stop stop',
|
||||
'that is all',
|
||||
"that's all",
|
||||
'never mind',
|
||||
'nevermind',
|
||||
'end conversation',
|
||||
'end the conversation',
|
||||
'goodbye',
|
||||
'good bye',
|
||||
'bye',
|
||||
'cancel'
|
||||
]
|
||||
|
||||
// Optional address prefixes so "hermes stop" / "ok stop" / "hey hermes, stop"
|
||||
// still count. Stripped before matching the core phrase.
|
||||
const ADDRESS_PREFIXES: readonly string[] = ['hey hermes', 'hey hermes,', 'hermes', 'hermes,', 'ok', 'okay', 'hey']
|
||||
|
||||
// Normalise: lowercase, strip surrounding punctuation/whitespace, collapse
|
||||
// internal runs of spaces. Trailing punctuation (".", "!", "…") is common in
|
||||
// STT output and must not defeat the match.
|
||||
function normalize(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[.,!?;:…]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripAddress(text: string): string {
|
||||
for (const prefix of ADDRESS_PREFIXES) {
|
||||
if (text === prefix) {
|
||||
// Bare address ("hermes") is not a stop command on its own.
|
||||
continue
|
||||
}
|
||||
|
||||
if (text.startsWith(`${prefix} `)) {
|
||||
return text.slice(prefix.length + 1).trim()
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the entire spoken utterance is a stop command (optionally addressed
|
||||
* to Hermes). Returns false for anything that merely contains "stop" as part of
|
||||
* a longer, substantive request.
|
||||
*/
|
||||
export function isVoiceStopCommand(transcript: string): boolean {
|
||||
if (!transcript) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = normalize(transcript)
|
||||
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Match with the address prefix stripped, and also as-is (so a bare "stop"
|
||||
// with no prefix still matches, and "please stop" — where "please" isn't a
|
||||
// prefix — matches directly).
|
||||
const candidates = new Set([normalized, stripAddress(normalized)])
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (STOP_PHRASES.includes(candidate)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
@ -28,6 +28,12 @@ to the agent.
|
|||
|
||||
It is **off by default** — nothing listens until you turn it on.
|
||||
|
||||
On the desktop app, a hands-free voice conversation can be ended by simply
|
||||
**saying "stop"** (or "never mind", "goodbye", "cancel", "that's all") — the
|
||||
spoken command ends the conversation instead of being sent to the agent. Only a
|
||||
whole-utterance stop command matches, so a real request like "stop the docker
|
||||
container" still goes through normally.
|
||||
|
||||
## Engines
|
||||
|
||||
| Engine | Cost | API key | Notes |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue