diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7fbe9efa4a25..5933beab4831 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,11 +1,14 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons' +import { AudioLines, Layers3, Loader2, Mic, Square, SteeringWheel } from '@/lib/icons' import { cn } from '@/lib/utils' +import { useMemo } from 'react' +import type { MicrophoneDevice } from './hooks/use-mic-device' import type { ConversationStatus } from './hooks/use-voice-conversation' import type { ChatBarState, VoiceStatus } from './types' @@ -43,10 +46,13 @@ export function ComposerControls({ conversation, disabled, hasComposerPayload, + selectedVoiceDeviceId, state, + voiceDevices, voiceStatus, onDictate, - onSteer + onSteer, + onChangeVoiceDevice }: { busy: boolean busyAction: 'queue' | 'stop' @@ -55,10 +61,13 @@ export function ComposerControls({ conversation: ConversationProps disabled: boolean hasComposerPayload: boolean + selectedVoiceDeviceId?: string | null state: ChatBarState + voiceDevices?: MicrophoneDevice[] voiceStatus: VoiceStatus onDictate: () => void onSteer: () => void + onChangeVoiceDevice?: (deviceId: string | null) => void }) { const { t } = useI18n() const c = t.composer @@ -69,9 +78,24 @@ export function ComposerControls({ const showVoicePrimary = !busy && !hasComposerPayload + const selectedLabel = useMemo(() => { + const match = voiceDevices?.find(device => device.deviceId === selectedVoiceDeviceId) + + return match?.label ?? '' + }, [selectedVoiceDeviceId, voiceDevices]) + return (
+ {(selectedLabel || voiceDevices?.length) && ( + + )} {canSteer && ( + + + {devices.map(device => ( + onChange?.(device.deviceId === value ? null : device.deviceId)} + > + {device.label} + {device.deviceId === value && } + + ))} + + + ) +} + +function MicLabel({ label }: { label: string }) { + if (!label) { + return + } + + return ( + + {label} + + ) +} + function DictationButton({ disabled, state, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-device.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-device.ts new file mode 100644 index 000000000000..59c0aa1527e2 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-device.ts @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from 'react' + +const STORAGE_KEY = 'hermes-voice-selected-device-id' + +export interface MicrophoneDevice { + deviceId: string + label: string +} + +export function useMicDevice() { + const [selectedDeviceId, setSelectedDeviceId] = useState(() => { + if (typeof window === 'undefined') { + return null + } + + return window.localStorage.getItem(STORAGE_KEY) + }) + const [devices, setDevices] = useState([]) + const [pendingDeviceId, setPendingDeviceId] = useState(null) + const initializedRef = useRef(false) + + const refreshDevices = async () => { + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) { + return + } + + try { + const all = await navigator.mediaDevices.enumerateDevices() + const inputs = all + .filter((device): device is MediaDeviceInfo & { deviceId: string } => device.kind === 'audioinput') + .map(device => ({ + deviceId: device.deviceId, + label: device.label || `Microphone ${device.deviceId.slice(0, 6)}` + })) + + setDevices(inputs) + } catch { + // enumeration is best-effort + } + } + + const chooseDevice = async (deviceId: string | null) => { + if (!deviceId) { + setPendingDeviceId(null) + setSelectedDeviceId(null) + if (typeof window !== 'undefined') { + window.localStorage.removeItem(STORAGE_KEY) + } + return + } + + try { + await navigator.mediaDevices.getUserMedia({ + audio: { deviceId: { exact: deviceId } } + }) + } catch { + // keep previous choice if probing fails + } + + setPendingDeviceId(deviceId) + setSelectedDeviceId(deviceId) + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, deviceId) + } + } + + useEffect(() => { + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { + return + } + + let cancelled = false + + async function prime() { + try { + await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch { + // non-fatal: labels may stay empty until permission is granted + } + + if (cancelled) { + return + } + + await refreshDevices() + + if (cancelled) { + return + } + + const current = selectedDeviceId + if (current && !devices.some(device => device.deviceId === current)) { + await chooseDevice(devices[0]?.deviceId ?? null) + } + + initializedRef.current = true + } + + prime() + + return () => { + cancelled = true + } + }, []) + + const clear = () => { + setPendingDeviceId(null) + setSelectedDeviceId(null) + if (typeof window !== 'undefined') { + window.localStorage.removeItem(STORAGE_KEY) + } + } + + return { + clear, + chooseDevice, + devices, + pendingDeviceId, + refreshDevices, + selectedDeviceId + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts index 8823084a36e6..9f55e7396d41 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' type BrowserAudioContext = typeof AudioContext export interface MicRecorderOptions { + deviceId?: string onLevel?: (level: number) => void onError?: (error: Error) => void onSilence?: () => void @@ -180,9 +181,14 @@ export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorde let stream: MediaStream try { - stream = await navigator.mediaDevices.getUserMedia({ - audio: { echoCancellation: true, noiseSuppression: true } - }) + const constraints: MediaStreamConstraints = { + audio: { + deviceId: options.deviceId ? { exact: options.deviceId } : undefined, + echoCancellation: true, + noiseSuppression: true + } + } + stream = await navigator.mediaDevices.getUserMedia(constraints) } catch (error) { throw micError(error, copy) } 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 e4e8f3201bed..cbba2ca32cad 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 @@ -16,6 +16,7 @@ interface PendingVoiceResponse { interface VoiceConversationOptions { busy: boolean + deviceId?: string enabled: boolean onFatalError?: () => void onSubmit: (text: string) => Promise | void @@ -26,6 +27,7 @@ interface VoiceConversationOptions { export function useVoiceConversation({ busy, + deviceId, enabled, onFatalError, onSubmit, @@ -198,8 +200,8 @@ export function useVoiceConversation({ } try { - // VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI. await handle.start({ + deviceId, silenceLevel: 0.075, silenceMs: 1_250, idleSilenceMs: 12_000, @@ -218,7 +220,7 @@ export function useVoiceConversation({ setStatus('idle') onFatalError?.() } - }, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed]) + }, [handle, deviceId, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed]) const speak = useCallback(async (text: string) => { setStatus('speaking') diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts index 937f2d3bc03f..dac75dd7f645 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts @@ -8,6 +8,7 @@ import type { VoiceActivityState, VoiceStatus } from '../types' import { useMicRecorder } from './use-mic-recorder' interface VoiceRecorderOptions { + deviceId?: string maxRecordingSeconds: number onTranscribeAudio?: (audio: Blob) => Promise focusInput: () => void @@ -15,6 +16,7 @@ interface VoiceRecorderOptions { } export function useVoiceRecorder({ + deviceId, maxRecordingSeconds, onTranscribeAudio, focusInput, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 9041fe89505a..c5c6e8050e48 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1361,9 +1361,14 @@ export function ChatBar({ }} disabled={disabled} hasComposerPayload={hasComposerPayload} + selectedVoiceDeviceId={state.voice.deviceId} onDictate={dictate} onSteer={steerDraft} + onChangeVoiceDevice={deviceId => { + setState(prev => prev satisfies ChatBarState ? { ...prev, voice: { ...prev.voice, deviceId: deviceId ?? '' } } as ChatBarState : prev) + }} state={state} + voiceDevices={[]} voiceStatus={voiceStatus} /> ) diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 36b3b8e6d3d8..3d097ea1640f 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -24,7 +24,7 @@ export interface ChatBarState { quickModels?: QuickModelOption[] } tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] } - voice: { enabled: boolean; active: boolean } + voice: { deviceId?: string; enabled: boolean; active: boolean } } export interface ChatBarProps { diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index 5bec5baca920..cbe346c53839 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -270,13 +270,23 @@ async def disconnect(self) -> None: async def _cleanup_ws(self) -> None: """Close the live websocket/session, if any.""" + pending_tasks = getattr(self, "_pending_text_batch_tasks", None) + if pending_tasks is not None: + for task in pending_tasks.values(): + if not task.done(): + task.cancel() + pending_tasks.clear() + pending_events = getattr(self, "_pending_text_batches", None) + if pending_events is not None: + pending_events.clear() if self._ws and not self._ws.closed: await self._ws.close() self._ws = None - if self._session and not self._session.closed: + session = getattr(self, "_session", None) + if session is not None and not session.closed: await self._session.close() - self._session = None + self._session = None async def _open_connection(self) -> None: """Open and authenticate a websocket connection.""" diff --git a/tests/gateway/test_wecom.py b/tests/gateway/test_wecom.py index c0999a98040b..6c27666a59ac 100644 --- a/tests/gateway/test_wecom.py +++ b/tests/gateway/test_wecom.py @@ -953,3 +953,21 @@ async def fake_handle(evt): assert adapter._pending_text_batches.get(key) is None, ( "active task must pop the event after processing" ) + + +def test_cleanup_ws_clears_pending_batch_state_without_assuming_full_init(): + from gateway.platforms.wecom import WeComAdapter + + adapter = WeComAdapter.__new__(WeComAdapter) + adapter._running = False + adapter._ws = None + adapter._session = None + adapter._pending_text_batch_tasks = {} + adapter._pending_text_batches = {} + + async def _drive(): + await adapter._cleanup_ws() + + asyncio.run(_drive()) + assert adapter._pending_text_batch_tasks == {} + assert adapter._pending_text_batches == {}