From 09c62d5da3509cf5d12236971fbdd1b0141b83a3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:03:11 -0700 Subject: [PATCH] feat(desktop): end a hands-free voice conversation by saying "stop" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../chat/composer/hooks/use-composer-voice.ts | 5 + .../composer/hooks/use-voice-conversation.ts | 21 +++++ apps/desktop/src/lib/voice-stop-word.test.ts | 62 ++++++++++++ apps/desktop/src/lib/voice-stop-word.ts | 94 +++++++++++++++++++ website/docs/user-guide/features/wake-word.md | 6 ++ 5 files changed, 188 insertions(+) create mode 100644 apps/desktop/src/lib/voice-stop-word.test.ts create mode 100644 apps/desktop/src/lib/voice-stop-word.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 414676e3c9b9..608431d2f7a1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -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 diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 33285f05aa16..58dea1b7fe27 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -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 onTranscribeAudio?: (audio: Blob) => Promise 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('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) diff --git a/apps/desktop/src/lib/voice-stop-word.test.ts b/apps/desktop/src/lib/voice-stop-word.test.ts new file mode 100644 index 000000000000..d1aa53b4fea0 --- /dev/null +++ b/apps/desktop/src/lib/voice-stop-word.test.ts @@ -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) + } + }) +}) diff --git a/apps/desktop/src/lib/voice-stop-word.ts b/apps/desktop/src/lib/voice-stop-word.ts new file mode 100644 index 000000000000..e59c9da83afb --- /dev/null +++ b/apps/desktop/src/lib/voice-stop-word.ts @@ -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 +} diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index ee57d9bf45d1..e8438d765f23 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -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 |