aicodeman 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -4
- package/dist/ai-idle-checker.d.ts.map +1 -1
- package/dist/ai-idle-checker.js +3 -2
- package/dist/ai-idle-checker.js.map +1 -1
- package/dist/ai-plan-checker.d.ts.map +1 -1
- package/dist/ai-plan-checker.js +3 -2
- package/dist/ai-plan-checker.js.map +1 -1
- package/dist/bash-tool-parser.d.ts +2 -3
- package/dist/bash-tool-parser.d.ts.map +1 -1
- package/dist/bash-tool-parser.js +14 -31
- package/dist/bash-tool-parser.js.map +1 -1
- package/dist/config/ai-defaults.d.ts +16 -0
- package/dist/config/ai-defaults.d.ts.map +1 -0
- package/dist/config/ai-defaults.js +16 -0
- package/dist/config/ai-defaults.js.map +1 -0
- package/dist/config/auth-config.d.ts +19 -0
- package/dist/config/auth-config.d.ts.map +1 -0
- package/dist/config/auth-config.js +28 -0
- package/dist/config/auth-config.js.map +1 -0
- package/dist/config/exec-timeout.d.ts +10 -0
- package/dist/config/exec-timeout.d.ts.map +1 -0
- package/dist/config/exec-timeout.js +10 -0
- package/dist/config/exec-timeout.js.map +1 -0
- package/dist/config/map-limits.d.ts +4 -0
- package/dist/config/map-limits.d.ts.map +1 -1
- package/dist/config/map-limits.js +7 -0
- package/dist/config/map-limits.js.map +1 -1
- package/dist/config/server-timing.d.ts +42 -0
- package/dist/config/server-timing.d.ts.map +1 -0
- package/dist/config/server-timing.js +57 -0
- package/dist/config/server-timing.js.map +1 -0
- package/dist/config/team-config.d.ts +16 -0
- package/dist/config/team-config.d.ts.map +1 -0
- package/dist/config/team-config.js +16 -0
- package/dist/config/team-config.js.map +1 -0
- package/dist/config/terminal-limits.d.ts +18 -0
- package/dist/config/terminal-limits.d.ts.map +1 -0
- package/dist/config/terminal-limits.js +18 -0
- package/dist/config/terminal-limits.js.map +1 -0
- package/dist/config/tunnel-config.d.ts +27 -0
- package/dist/config/tunnel-config.d.ts.map +1 -0
- package/dist/config/tunnel-config.js +36 -0
- package/dist/config/tunnel-config.js.map +1 -0
- package/dist/hooks-config.d.ts +21 -6
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +28 -12
- package/dist/hooks-config.js.map +1 -1
- package/dist/image-watcher.d.ts +4 -4
- package/dist/image-watcher.d.ts.map +1 -1
- package/dist/image-watcher.js +17 -30
- package/dist/image-watcher.js.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/plan-orchestrator.d.ts +2 -24
- package/dist/plan-orchestrator.d.ts.map +1 -1
- package/dist/plan-orchestrator.js.map +1 -1
- package/dist/prompts/planner.d.ts +7 -8
- package/dist/prompts/planner.d.ts.map +1 -1
- package/dist/prompts/planner.js +7 -8
- package/dist/prompts/planner.js.map +1 -1
- package/dist/prompts/research-agent.d.ts +6 -4
- package/dist/prompts/research-agent.d.ts.map +1 -1
- package/dist/prompts/research-agent.js +6 -4
- package/dist/prompts/research-agent.js.map +1 -1
- package/dist/push-store.d.ts +1 -1
- package/dist/push-store.d.ts.map +1 -1
- package/dist/push-store.js +4 -12
- package/dist/push-store.js.map +1 -1
- package/dist/ralph-fix-plan-watcher.d.ts +91 -0
- package/dist/ralph-fix-plan-watcher.d.ts.map +1 -0
- package/dist/ralph-fix-plan-watcher.js +326 -0
- package/dist/ralph-fix-plan-watcher.js.map +1 -0
- package/dist/ralph-loop.d.ts +14 -4
- package/dist/ralph-loop.d.ts.map +1 -1
- package/dist/ralph-loop.js +14 -4
- package/dist/ralph-loop.js.map +1 -1
- package/dist/ralph-plan-tracker.d.ts +201 -0
- package/dist/ralph-plan-tracker.d.ts.map +1 -0
- package/dist/ralph-plan-tracker.js +325 -0
- package/dist/ralph-plan-tracker.js.map +1 -0
- package/dist/ralph-stall-detector.d.ts +84 -0
- package/dist/ralph-stall-detector.d.ts.map +1 -0
- package/dist/ralph-stall-detector.js +139 -0
- package/dist/ralph-stall-detector.js.map +1 -0
- package/dist/ralph-status-parser.d.ts +141 -0
- package/dist/ralph-status-parser.d.ts.map +1 -0
- package/dist/ralph-status-parser.js +478 -0
- package/dist/ralph-status-parser.js.map +1 -0
- package/dist/ralph-tracker.d.ts +218 -692
- package/dist/ralph-tracker.d.ts.map +1 -1
- package/dist/ralph-tracker.js +389 -1723
- package/dist/ralph-tracker.js.map +1 -1
- package/dist/respawn-adaptive-timing.d.ts +61 -0
- package/dist/respawn-adaptive-timing.d.ts.map +1 -0
- package/dist/respawn-adaptive-timing.js +105 -0
- package/dist/respawn-adaptive-timing.js.map +1 -0
- package/dist/respawn-controller.d.ts +35 -115
- package/dist/respawn-controller.d.ts.map +1 -1
- package/dist/respawn-controller.js +167 -607
- package/dist/respawn-controller.js.map +1 -1
- package/dist/respawn-health.d.ts +54 -0
- package/dist/respawn-health.d.ts.map +1 -0
- package/dist/respawn-health.js +183 -0
- package/dist/respawn-health.js.map +1 -0
- package/dist/respawn-metrics.d.ts +81 -0
- package/dist/respawn-metrics.d.ts.map +1 -0
- package/dist/respawn-metrics.js +198 -0
- package/dist/respawn-metrics.js.map +1 -0
- package/dist/respawn-patterns.d.ts +45 -0
- package/dist/respawn-patterns.d.ts.map +1 -0
- package/dist/respawn-patterns.js +125 -0
- package/dist/respawn-patterns.js.map +1 -0
- package/dist/session-auto-ops.d.ts +89 -0
- package/dist/session-auto-ops.d.ts.map +1 -0
- package/dist/session-auto-ops.js +224 -0
- package/dist/session-auto-ops.js.map +1 -0
- package/dist/session-cli-builder.d.ts +62 -0
- package/dist/session-cli-builder.d.ts.map +1 -0
- package/dist/session-cli-builder.js +121 -0
- package/dist/session-cli-builder.js.map +1 -0
- package/dist/session-manager.d.ts +17 -5
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +17 -5
- package/dist/session-manager.js.map +1 -1
- package/dist/session-task-cache.d.ts +52 -0
- package/dist/session-task-cache.d.ts.map +1 -0
- package/dist/session-task-cache.js +90 -0
- package/dist/session-task-cache.js.map +1 -0
- package/dist/session.d.ts +23 -41
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +79 -317
- package/dist/session.js.map +1 -1
- package/dist/state-store.d.ts +19 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +29 -30
- package/dist/state-store.js.map +1 -1
- package/dist/subagent-watcher.d.ts +26 -7
- package/dist/subagent-watcher.d.ts.map +1 -1
- package/dist/subagent-watcher.js +47 -64
- package/dist/subagent-watcher.js.map +1 -1
- package/dist/team-watcher.d.ts.map +1 -1
- package/dist/team-watcher.js +2 -5
- package/dist/team-watcher.js.map +1 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +1 -2
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tunnel-manager.d.ts +26 -0
- package/dist/tunnel-manager.d.ts.map +1 -1
- package/dist/tunnel-manager.js +126 -7
- package/dist/tunnel-manager.js.map +1 -1
- package/dist/types/api.d.ts +108 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/types/api.js +98 -0
- package/dist/types/api.js.map +1 -0
- package/dist/types/app-state.d.ts +117 -0
- package/dist/types/app-state.d.ts.map +1 -0
- package/dist/types/app-state.js +76 -0
- package/dist/types/app-state.js.map +1 -0
- package/dist/types/common.d.ts +79 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +17 -0
- package/dist/types/common.js.map +1 -0
- package/dist/types/index.d.ts +66 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +66 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/lifecycle.d.ts +28 -0
- package/dist/types/lifecycle.d.ts.map +1 -0
- package/dist/types/lifecycle.js +16 -0
- package/dist/types/lifecycle.js.map +1 -0
- package/dist/types/plan.d.ts +45 -0
- package/dist/types/plan.d.ts.map +1 -0
- package/dist/types/plan.js +18 -0
- package/dist/types/plan.js.map +1 -0
- package/dist/types/push.d.ts +36 -0
- package/dist/types/push.d.ts.map +1 -0
- package/dist/types/push.js +18 -0
- package/dist/types/push.js.map +1 -0
- package/dist/types/ralph.d.ts +262 -0
- package/dist/types/ralph.d.ts.map +1 -0
- package/dist/types/ralph.js +70 -0
- package/dist/types/ralph.js.map +1 -0
- package/dist/types/respawn.d.ts +271 -0
- package/dist/types/respawn.d.ts.map +1 -0
- package/dist/types/respawn.js +26 -0
- package/dist/types/respawn.js.map +1 -0
- package/dist/types/run-summary.d.ts +96 -0
- package/dist/types/run-summary.d.ts.map +1 -0
- package/dist/types/run-summary.js +37 -0
- package/dist/types/run-summary.js.map +1 -0
- package/dist/types/session.d.ts +152 -0
- package/dist/types/session.d.ts.map +1 -0
- package/dist/types/session.js +27 -0
- package/dist/types/session.js.map +1 -0
- package/dist/types/task.d.ts +72 -0
- package/dist/types/task.d.ts.map +1 -0
- package/dist/types/task.js +19 -0
- package/dist/types/task.js.map +1 -0
- package/dist/types/teams.d.ts +73 -0
- package/dist/types/teams.d.ts.map +1 -0
- package/dist/types/teams.js +23 -0
- package/dist/types/teams.js.map +1 -0
- package/dist/types/tools.d.ts +61 -0
- package/dist/types/tools.d.ts.map +1 -0
- package/dist/types/tools.js +20 -0
- package/dist/types/tools.js.map +1 -0
- package/dist/types.d.ts +8 -1134
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -210
- package/dist/types.js.map +1 -1
- package/dist/utils/claude-cli-resolver.d.ts.map +1 -1
- package/dist/utils/claude-cli-resolver.js +1 -2
- package/dist/utils/claude-cli-resolver.js.map +1 -1
- package/dist/utils/debouncer.d.ts +111 -0
- package/dist/utils/debouncer.d.ts.map +1 -0
- package/dist/utils/debouncer.js +162 -0
- package/dist/utils/debouncer.js.map +1 -0
- package/dist/utils/index.d.ts +3 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -2
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/opencode-cli-resolver.d.ts.map +1 -1
- package/dist/utils/opencode-cli-resolver.js +1 -2
- package/dist/utils/opencode-cli-resolver.js.map +1 -1
- package/dist/utils/string-similarity.d.ts +0 -57
- package/dist/utils/string-similarity.d.ts.map +1 -1
- package/dist/utils/string-similarity.js +3 -18
- package/dist/utils/string-similarity.js.map +1 -1
- package/dist/web/middleware/auth.d.ts +31 -0
- package/dist/web/middleware/auth.d.ts.map +1 -0
- package/dist/web/middleware/auth.js +154 -0
- package/dist/web/middleware/auth.js.map +1 -0
- package/dist/web/ports/auth-port.d.ts +18 -0
- package/dist/web/ports/auth-port.d.ts.map +1 -0
- package/dist/web/ports/auth-port.js +6 -0
- package/dist/web/ports/auth-port.js.map +1 -0
- package/dist/web/ports/config-port.d.ts +28 -0
- package/dist/web/ports/config-port.d.ts.map +1 -0
- package/dist/web/ports/config-port.js +6 -0
- package/dist/web/ports/config-port.js.map +1 -0
- package/dist/web/ports/event-port.d.ts +13 -0
- package/dist/web/ports/event-port.d.ts.map +1 -0
- package/dist/web/ports/event-port.js +6 -0
- package/dist/web/ports/event-port.js.map +1 -0
- package/dist/web/ports/index.d.ts +14 -0
- package/dist/web/ports/index.d.ts.map +1 -0
- package/dist/web/ports/index.js +9 -0
- package/dist/web/ports/index.js.map +1 -0
- package/dist/web/ports/infra-port.d.ts +36 -0
- package/dist/web/ports/infra-port.d.ts.map +1 -0
- package/dist/web/ports/infra-port.js +6 -0
- package/dist/web/ports/infra-port.js.map +1 -0
- package/dist/web/ports/respawn-port.d.ts +20 -0
- package/dist/web/ports/respawn-port.d.ts.map +1 -0
- package/dist/web/ports/respawn-port.js +6 -0
- package/dist/web/ports/respawn-port.js.map +1 -0
- package/dist/web/ports/session-port.d.ts +15 -0
- package/dist/web/ports/session-port.d.ts.map +1 -0
- package/dist/web/ports/session-port.js +6 -0
- package/dist/web/ports/session-port.js.map +1 -0
- package/dist/web/public/api-client.js +82 -0
- package/dist/web/public/api-client.js.br +0 -0
- package/dist/web/public/api-client.js.gz +0 -0
- package/dist/web/public/app.js +117 -201
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/constants.js +365 -0
- package/dist/web/public/constants.js.br +0 -0
- package/dist/web/public/constants.js.gz +0 -0
- package/dist/web/public/index.html +15 -3
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/keyboard-accessory.js +302 -0
- package/dist/web/public/keyboard-accessory.js.br +0 -0
- package/dist/web/public/keyboard-accessory.js.gz +0 -0
- package/dist/web/public/mobile-handlers.js +491 -0
- package/dist/web/public/mobile-handlers.js.br +0 -0
- package/dist/web/public/mobile-handlers.js.gz +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/notification-manager.js +472 -0
- package/dist/web/public/notification-manager.js.br +0 -0
- package/dist/web/public/notification-manager.js.gz +0 -0
- package/dist/web/public/ralph-wizard.js +33 -9
- package/dist/web/public/ralph-wizard.js.br +0 -0
- package/dist/web/public/ralph-wizard.js.gz +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/subagent-windows.js +1149 -0
- package/dist/web/public/subagent-windows.js.br +0 -0
- package/dist/web/public/subagent-windows.js.gz +0 -0
- package/dist/web/public/sw.js +15 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.js +4 -0
- package/dist/web/public/vendor/xterm-zerolag-input.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.js +882 -0
- package/dist/web/public/voice-input.js.br +0 -0
- package/dist/web/public/voice-input.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +38 -0
- package/dist/web/route-helpers.d.ts.map +1 -0
- package/dist/web/route-helpers.js +144 -0
- package/dist/web/route-helpers.js.map +1 -0
- package/dist/web/routes/case-routes.d.ts +9 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -0
- package/dist/web/routes/case-routes.js +426 -0
- package/dist/web/routes/case-routes.js.map +1 -0
- package/dist/web/routes/file-routes.d.ts +8 -0
- package/dist/web/routes/file-routes.d.ts.map +1 -0
- package/dist/web/routes/file-routes.js +337 -0
- package/dist/web/routes/file-routes.js.map +1 -0
- package/dist/web/routes/hook-event-routes.d.ts +9 -0
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -0
- package/dist/web/routes/hook-event-routes.js +57 -0
- package/dist/web/routes/hook-event-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +16 -0
- package/dist/web/routes/index.d.ts.map +1 -0
- package/dist/web/routes/index.js +16 -0
- package/dist/web/routes/index.js.map +1 -0
- package/dist/web/routes/mux-routes.d.ts +8 -0
- package/dist/web/routes/mux-routes.d.ts.map +1 -0
- package/dist/web/routes/mux-routes.js +32 -0
- package/dist/web/routes/mux-routes.js.map +1 -0
- package/dist/web/routes/plan-routes.d.ts +9 -0
- package/dist/web/routes/plan-routes.d.ts.map +1 -0
- package/dist/web/routes/plan-routes.js +385 -0
- package/dist/web/routes/plan-routes.js.map +1 -0
- package/dist/web/routes/push-routes.d.ts +8 -0
- package/dist/web/routes/push-routes.d.ts.map +1 -0
- package/dist/web/routes/push-routes.js +49 -0
- package/dist/web/routes/push-routes.js.map +1 -0
- package/dist/web/routes/ralph-routes.d.ts +9 -0
- package/dist/web/routes/ralph-routes.d.ts.map +1 -0
- package/dist/web/routes/ralph-routes.js +485 -0
- package/dist/web/routes/ralph-routes.js.map +1 -0
- package/dist/web/routes/respawn-routes.d.ts +8 -0
- package/dist/web/routes/respawn-routes.d.ts.map +1 -0
- package/dist/web/routes/respawn-routes.js +270 -0
- package/dist/web/routes/respawn-routes.js.map +1 -0
- package/dist/web/routes/scheduled-routes.d.ts +8 -0
- package/dist/web/routes/scheduled-routes.d.ts.map +1 -0
- package/dist/web/routes/scheduled-routes.js +51 -0
- package/dist/web/routes/scheduled-routes.js.map +1 -0
- package/dist/web/routes/session-routes.d.ts +9 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -0
- package/dist/web/routes/session-routes.js +751 -0
- package/dist/web/routes/session-routes.js.map +1 -0
- package/dist/web/routes/system-routes.d.ts +9 -0
- package/dist/web/routes/system-routes.d.ts.map +1 -0
- package/dist/web/routes/system-routes.js +699 -0
- package/dist/web/routes/system-routes.js.map +1 -0
- package/dist/web/routes/team-routes.d.ts +8 -0
- package/dist/web/routes/team-routes.d.ts.map +1 -0
- package/dist/web/routes/team-routes.js +14 -0
- package/dist/web/routes/team-routes.js.map +1 -0
- package/dist/web/schemas.d.ts +43 -3
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +6 -2
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +35 -15
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +563 -3971
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +361 -0
- package/dist/web/sse-events.d.ts.map +1 -0
- package/dist/web/sse-events.js +396 -0
- package/dist/web/sse-events.js.map +1 -0
- package/package.json +2 -1
- package/scripts/postinstall.js +58 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Voice input with Deepgram Nova-3 (primary) and Web Speech API (fallback).
|
|
3
|
+
*
|
|
4
|
+
* Defines two singleton objects:
|
|
5
|
+
*
|
|
6
|
+
* - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text.
|
|
7
|
+
* Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings,
|
|
8
|
+
* auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary.
|
|
9
|
+
*
|
|
10
|
+
* - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap
|
|
11
|
+
* again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording
|
|
12
|
+
* indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct"
|
|
13
|
+
* (inject into local echo overlay or PTY) and "compose" (editable textarea overlay).
|
|
14
|
+
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
|
|
15
|
+
* Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
|
|
16
|
+
*
|
|
17
|
+
* @globals {object} DeepgramProvider
|
|
18
|
+
* @globals {object} VoiceInput
|
|
19
|
+
*
|
|
20
|
+
* @dependency mobile-handlers.js (MobileDetection for device checks)
|
|
21
|
+
* @dependency app.js (uses global `app` for sendInput, showToast, terminal focus)
|
|
22
|
+
* @loadorder 3 of 9 — loaded after mobile-handlers.js, before notification-manager.js
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
|
|
26
|
+
// Loaded after mobile-handlers.js, before app.js
|
|
27
|
+
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════
|
|
29
|
+
// Voice Input (Deepgram Nova-3 + Web Speech API fallback)
|
|
30
|
+
// ═══════════════════════════════════════════════════════════════
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API.
|
|
34
|
+
* Direct browser-to-Deepgram connection (no server proxy).
|
|
35
|
+
* Uses MediaRecorder to capture audio and streams via WebSocket.
|
|
36
|
+
*/
|
|
37
|
+
const DeepgramProvider = {
|
|
38
|
+
_ws: null,
|
|
39
|
+
_mediaRecorder: null,
|
|
40
|
+
_stream: null,
|
|
41
|
+
_silenceTimeout: null,
|
|
42
|
+
_keepAliveInterval: null,
|
|
43
|
+
_onResult: null,
|
|
44
|
+
_onError: null,
|
|
45
|
+
_onEnd: null,
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Start streaming audio to Deepgram.
|
|
49
|
+
* @param {object} opts - { apiKey, language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
|
|
50
|
+
*/
|
|
51
|
+
async start(opts) {
|
|
52
|
+
this._onResult = opts.onResult;
|
|
53
|
+
this._onError = opts.onError;
|
|
54
|
+
this._onEnd = opts.onEnd;
|
|
55
|
+
|
|
56
|
+
// 1. Get microphone access
|
|
57
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
58
|
+
this._onError?.('Microphone requires a secure context (HTTPS). Use --https flag or access via localhost.');
|
|
59
|
+
this._cleanup();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
this._stream = await navigator.mediaDevices.getUserMedia({
|
|
64
|
+
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const msg = err.name === 'NotAllowedError'
|
|
68
|
+
? 'Microphone access denied. Check browser settings.'
|
|
69
|
+
: 'Microphone error: ' + err.message;
|
|
70
|
+
this._onError?.(msg);
|
|
71
|
+
this._cleanup();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Notify caller so it can set up audio level meter
|
|
75
|
+
opts.onStream?.(this._stream);
|
|
76
|
+
|
|
77
|
+
// 2. Detect best supported MIME type for MediaRecorder
|
|
78
|
+
const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'];
|
|
79
|
+
this._selectedMime = null;
|
|
80
|
+
for (const mt of mimeTypes) {
|
|
81
|
+
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(mt)) {
|
|
82
|
+
this._selectedMime = mt;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 3. Build WebSocket URL (no encoding param — Deepgram auto-detects from container format)
|
|
88
|
+
|
|
89
|
+
const params = new URLSearchParams({
|
|
90
|
+
model: 'nova-3',
|
|
91
|
+
smart_format: 'false',
|
|
92
|
+
punctuate: 'false',
|
|
93
|
+
interim_results: 'true',
|
|
94
|
+
utterance_end_ms: '1500',
|
|
95
|
+
vad_events: 'true',
|
|
96
|
+
});
|
|
97
|
+
if (opts.language && opts.language !== 'multi') {
|
|
98
|
+
params.set('language', opts.language);
|
|
99
|
+
} else if (opts.language === 'multi') {
|
|
100
|
+
params.set('detect_language', 'true');
|
|
101
|
+
}
|
|
102
|
+
if (opts.keyterms?.length) {
|
|
103
|
+
for (const term of opts.keyterms) {
|
|
104
|
+
const trimmed = term.trim();
|
|
105
|
+
if (trimmed) params.append('keyterm', trimmed + ':2');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. Connect WebSocket (trim API key to avoid whitespace auth failures)
|
|
110
|
+
const apiKey = (opts.apiKey || '').trim();
|
|
111
|
+
if (!apiKey) {
|
|
112
|
+
this._onError?.('No Deepgram API key configured. Add one in Settings > Voice.');
|
|
113
|
+
this._cleanup();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const wsUrl = `wss://api.deepgram.com/v1/listen?${params}`;
|
|
117
|
+
try {
|
|
118
|
+
this._ws = new WebSocket(wsUrl, ['token', apiKey]);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this._onError?.('Failed to connect to Deepgram: ' + err.message);
|
|
121
|
+
this._cleanup();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this._ws.onopen = () => {
|
|
126
|
+
// 5. Send KeepAlive every 8s to prevent Deepgram from closing idle connections
|
|
127
|
+
// (covers the gap before MediaRecorder produces its first chunk)
|
|
128
|
+
this._keepAliveInterval = setInterval(() => {
|
|
129
|
+
if (this._ws?.readyState === WebSocket.OPEN) {
|
|
130
|
+
try { this._ws.send(JSON.stringify({ type: 'KeepAlive' })); } catch (_e) { /* ignore */ }
|
|
131
|
+
}
|
|
132
|
+
}, 8000);
|
|
133
|
+
// 6. Start MediaRecorder once connected
|
|
134
|
+
this._startRecording();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
this._ws.onmessage = (event) => {
|
|
138
|
+
try {
|
|
139
|
+
const data = JSON.parse(event.data);
|
|
140
|
+
if (data.type === 'Results' && data.channel?.alternatives?.[0]) {
|
|
141
|
+
const alt = data.channel.alternatives[0];
|
|
142
|
+
const transcript = alt.transcript || '';
|
|
143
|
+
if (transcript) {
|
|
144
|
+
const isFinal = data.is_final === true;
|
|
145
|
+
this._onResult?.(transcript, isFinal);
|
|
146
|
+
this._resetSilenceTimeout();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch (_e) {
|
|
150
|
+
// Ignore parse errors for non-JSON messages
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
this._ws.onerror = () => {
|
|
155
|
+
// WebSocket onerror doesn't carry useful info — onclose handles it
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this._ws.onclose = (event) => {
|
|
159
|
+
clearInterval(this._keepAliveInterval);
|
|
160
|
+
this._keepAliveInterval = null;
|
|
161
|
+
if (event.code === 1008) {
|
|
162
|
+
this._onError?.('Authentication failed. Check your Deepgram API key in Settings > Voice.');
|
|
163
|
+
} else if (event.code === 1006) {
|
|
164
|
+
// 1006 = abnormal closure (no close frame). Usually auth failure, expired key, or no credits.
|
|
165
|
+
this._onError?.('Deepgram connection failed (1006). Check your API key is valid and has credits in Settings > Voice.');
|
|
166
|
+
} else if (event.code !== 1000) {
|
|
167
|
+
this._onError?.('Deepgram connection closed: ' + (event.reason || `code ${event.code}`));
|
|
168
|
+
}
|
|
169
|
+
this._stopRecording();
|
|
170
|
+
this._onEnd?.();
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
_startRecording() {
|
|
175
|
+
if (!this._stream || !this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
|
176
|
+
|
|
177
|
+
const recorderOpts = this._selectedMime ? { mimeType: this._selectedMime } : {};
|
|
178
|
+
try {
|
|
179
|
+
this._mediaRecorder = new MediaRecorder(this._stream, recorderOpts);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
this._onError?.('MediaRecorder failed: ' + err.message);
|
|
182
|
+
this._cleanup();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this._mediaRecorder.ondataavailable = (event) => {
|
|
187
|
+
if (event.data.size > 0 && this._ws?.readyState === WebSocket.OPEN) {
|
|
188
|
+
this._ws.send(event.data);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
this._mediaRecorder.start(250); // Send chunks every 250ms
|
|
193
|
+
this._resetSilenceTimeout();
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
_stopRecording() {
|
|
197
|
+
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
|
|
198
|
+
try { this._mediaRecorder.stop(); } catch (_e) { /* already stopped */ }
|
|
199
|
+
}
|
|
200
|
+
// Stop all mic tracks
|
|
201
|
+
if (this._stream) {
|
|
202
|
+
this._stream.getTracks().forEach(t => t.stop());
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
_resetSilenceTimeout() {
|
|
207
|
+
clearTimeout(this._silenceTimeout);
|
|
208
|
+
this._silenceTimeout = setTimeout(() => {
|
|
209
|
+
this.stop();
|
|
210
|
+
}, 3000);
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
stop() {
|
|
214
|
+
clearTimeout(this._silenceTimeout);
|
|
215
|
+
this._silenceTimeout = null;
|
|
216
|
+
clearInterval(this._keepAliveInterval);
|
|
217
|
+
this._keepAliveInterval = null;
|
|
218
|
+
this._stopRecording();
|
|
219
|
+
// Detach WS handlers before closing to prevent stale onclose from
|
|
220
|
+
// killing a subsequent recording that starts before the close completes
|
|
221
|
+
if (this._ws) {
|
|
222
|
+
this._ws.onclose = null;
|
|
223
|
+
this._ws.onmessage = null;
|
|
224
|
+
this._ws.onerror = null;
|
|
225
|
+
if (this._ws.readyState === WebSocket.OPEN) {
|
|
226
|
+
try { this._ws.close(1000); } catch (_e) { /* ignore */ }
|
|
227
|
+
}
|
|
228
|
+
this._ws = null;
|
|
229
|
+
}
|
|
230
|
+
// Save onEnd before nulling — must notify VoiceInput when silence timeout
|
|
231
|
+
// triggers stop internally (VoiceInput.onEnd guards with isRecording check)
|
|
232
|
+
const onEnd = this._onEnd;
|
|
233
|
+
this._onResult = null;
|
|
234
|
+
this._onError = null;
|
|
235
|
+
this._onEnd = null;
|
|
236
|
+
onEnd?.();
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
_cleanup() {
|
|
240
|
+
this.stop();
|
|
241
|
+
this._mediaRecorder = null;
|
|
242
|
+
this._stream = null;
|
|
243
|
+
this._selectedMime = null;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* VoiceInput - Speech-to-text with Deepgram Nova-3 (primary) and Web Speech API (fallback).
|
|
249
|
+
* Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence.
|
|
250
|
+
* Shows interim transcription in a floating preview overlay.
|
|
251
|
+
* Inserts final text into the active session (user presses Enter to submit).
|
|
252
|
+
*/
|
|
253
|
+
const VoiceInput = {
|
|
254
|
+
recognition: null,
|
|
255
|
+
isRecording: false,
|
|
256
|
+
supported: false,
|
|
257
|
+
silenceTimeout: null,
|
|
258
|
+
previewEl: null,
|
|
259
|
+
_lastTranscript: '',
|
|
260
|
+
_stabilityTimer: null,
|
|
261
|
+
_accumulatedFinal: '',
|
|
262
|
+
_activeProvider: null, // 'deepgram' | 'webspeech' | null
|
|
263
|
+
_recordingStartedAt: 0, // timestamp when recording started
|
|
264
|
+
_retryCount: 0, // auto-retry counter for premature Web Speech API ends
|
|
265
|
+
_hasReceivedResult: false, // whether any speech result came in this session
|
|
266
|
+
_durationInterval: null, // timer for updating elapsed time display
|
|
267
|
+
_analyser: null, // AudioContext analyser for level meter
|
|
268
|
+
_analyserSource: null, // MediaStreamSource for level meter
|
|
269
|
+
_audioContext: null, // AudioContext for level meter
|
|
270
|
+
_levelAnimFrame: null, // rAF handle for level meter
|
|
271
|
+
|
|
272
|
+
init() {
|
|
273
|
+
this._initRecognition();
|
|
274
|
+
// Always show buttons — if unsupported, toggle() shows a toast
|
|
275
|
+
this._showButtons();
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// --- Deepgram config (localStorage only, never sent to server) ---
|
|
279
|
+
|
|
280
|
+
_getDeepgramConfig() {
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(localStorage.getItem('codeman-voice-settings') || '{}');
|
|
283
|
+
} catch (_e) {
|
|
284
|
+
return {};
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
_saveDeepgramConfig(config) {
|
|
289
|
+
localStorage.setItem('codeman-voice-settings', JSON.stringify(config));
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
_shouldUseDeepgram() {
|
|
293
|
+
const cfg = this._getDeepgramConfig();
|
|
294
|
+
return !!(cfg.apiKey && cfg.apiKey.trim());
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
/** Get the active provider name for display */
|
|
298
|
+
getActiveProviderName() {
|
|
299
|
+
if (this._shouldUseDeepgram()) return 'Deepgram Nova-3';
|
|
300
|
+
if (this.supported) return 'Web Speech API';
|
|
301
|
+
return 'None';
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/** Try to create a SpeechRecognition instance */
|
|
305
|
+
_initRecognition() {
|
|
306
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
307
|
+
this.supported = !!SR;
|
|
308
|
+
if (!this.supported) return;
|
|
309
|
+
|
|
310
|
+
this.recognition = new SR();
|
|
311
|
+
this.recognition.continuous = true;
|
|
312
|
+
this.recognition.interimResults = true;
|
|
313
|
+
this.recognition.lang = 'en-US';
|
|
314
|
+
this.recognition.maxAlternatives = 1;
|
|
315
|
+
|
|
316
|
+
this.recognition.onresult = (e) => this._onWebSpeechResult(e);
|
|
317
|
+
this.recognition.onerror = (e) => this._onWebSpeechError(e);
|
|
318
|
+
this.recognition.onend = () => this._onWebSpeechEnd();
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
toggle() {
|
|
322
|
+
if (this.isRecording) {
|
|
323
|
+
this.stop();
|
|
324
|
+
} else {
|
|
325
|
+
this.start();
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
start() {
|
|
330
|
+
if (this.isRecording) return;
|
|
331
|
+
if (!app.activeSessionId) {
|
|
332
|
+
app.showToast('No active session', 'warning');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this._retryCount = 0;
|
|
336
|
+
|
|
337
|
+
if (this._shouldUseDeepgram()) {
|
|
338
|
+
this._startDeepgram();
|
|
339
|
+
} else {
|
|
340
|
+
this._startWebSpeech();
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
_startDeepgram() {
|
|
345
|
+
const cfg = this._getDeepgramConfig();
|
|
346
|
+
this.isRecording = true;
|
|
347
|
+
this._activeProvider = 'deepgram';
|
|
348
|
+
this._accumulatedFinal = '';
|
|
349
|
+
this._lastTranscript = '';
|
|
350
|
+
this._hasReceivedResult = false;
|
|
351
|
+
this._recordingStartedAt = Date.now();
|
|
352
|
+
this._updateButtons('recording');
|
|
353
|
+
this._showPreview('Listening...', 'deepgram');
|
|
354
|
+
this._startDurationTimer();
|
|
355
|
+
|
|
356
|
+
const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com')
|
|
357
|
+
.split(',').map(t => t.trim()).filter(Boolean);
|
|
358
|
+
|
|
359
|
+
DeepgramProvider.start({
|
|
360
|
+
apiKey: cfg.apiKey,
|
|
361
|
+
language: cfg.language || 'en-US',
|
|
362
|
+
keyterms,
|
|
363
|
+
onStream: (stream) => {
|
|
364
|
+
this._startLevelMeter(stream);
|
|
365
|
+
},
|
|
366
|
+
onResult: (text, isFinal) => {
|
|
367
|
+
if (!this.isRecording) return;
|
|
368
|
+
this._hasReceivedResult = true;
|
|
369
|
+
if (isFinal) {
|
|
370
|
+
this._accumulatedFinal += text;
|
|
371
|
+
this._hidePreview();
|
|
372
|
+
this._insertText(this._accumulatedFinal);
|
|
373
|
+
this.stop();
|
|
374
|
+
} else {
|
|
375
|
+
const display = this._accumulatedFinal + text;
|
|
376
|
+
this._showPreview(display, 'deepgram');
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
onError: (msg) => {
|
|
380
|
+
const wasRecording = this.isRecording;
|
|
381
|
+
this.stop();
|
|
382
|
+
if (wasRecording) app.showToast(msg, 'error');
|
|
383
|
+
},
|
|
384
|
+
onEnd: () => {
|
|
385
|
+
if (this.isRecording) {
|
|
386
|
+
if (this._accumulatedFinal) {
|
|
387
|
+
this._insertText(this._accumulatedFinal);
|
|
388
|
+
}
|
|
389
|
+
this.stop();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Haptic feedback on mobile
|
|
395
|
+
if (navigator.vibrate) navigator.vibrate(50);
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
_startWebSpeech() {
|
|
399
|
+
// Lazy-init: retry if recognition was cleaned up or not available at page load
|
|
400
|
+
if (!this.recognition) this._initRecognition();
|
|
401
|
+
if (!this.supported) {
|
|
402
|
+
if (!this._shouldUseDeepgram()) {
|
|
403
|
+
app.showToast('Voice input not available. Configure Deepgram in Settings > Voice.', 'warning');
|
|
404
|
+
} else {
|
|
405
|
+
app.showToast('Voice input not supported in this browser', 'warning');
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.isRecording = true;
|
|
410
|
+
this._activeProvider = 'webspeech';
|
|
411
|
+
this._accumulatedFinal = '';
|
|
412
|
+
this._lastTranscript = '';
|
|
413
|
+
this._hasReceivedResult = false;
|
|
414
|
+
this._recordingStartedAt = Date.now();
|
|
415
|
+
this._updateButtons('recording');
|
|
416
|
+
this._showPreview('Listening...');
|
|
417
|
+
this._startDurationTimer();
|
|
418
|
+
try {
|
|
419
|
+
this.recognition.start();
|
|
420
|
+
} catch (e) {
|
|
421
|
+
// InvalidStateError = already started — ignore. Other errors = genuine failure.
|
|
422
|
+
if (e.name !== 'InvalidStateError') {
|
|
423
|
+
this.stop();
|
|
424
|
+
app.showToast('Voice input failed to start: ' + e.message, 'error');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
this._resetSilenceTimeout();
|
|
429
|
+
// Get mic stream for level meter (non-blocking — level meter is cosmetic)
|
|
430
|
+
navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => {
|
|
431
|
+
if (this.isRecording && this._activeProvider === 'webspeech') {
|
|
432
|
+
this._webSpeechStream = stream;
|
|
433
|
+
this._startLevelMeter(stream);
|
|
434
|
+
} else {
|
|
435
|
+
stream.getTracks().forEach(t => t.stop());
|
|
436
|
+
}
|
|
437
|
+
}).catch(() => { /* level meter just won't show */ });
|
|
438
|
+
// Haptic feedback on mobile
|
|
439
|
+
if (navigator.vibrate) navigator.vibrate(50);
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
stop() {
|
|
443
|
+
if (!this.isRecording) return;
|
|
444
|
+
this.isRecording = false;
|
|
445
|
+
clearTimeout(this.silenceTimeout);
|
|
446
|
+
clearTimeout(this._stabilityTimer);
|
|
447
|
+
this.silenceTimeout = null;
|
|
448
|
+
this._stabilityTimer = null;
|
|
449
|
+
this._retryCount = 0;
|
|
450
|
+
this._stopDurationTimer();
|
|
451
|
+
this._stopLevelMeter();
|
|
452
|
+
this._updateButtons('idle');
|
|
453
|
+
this._hidePreview();
|
|
454
|
+
|
|
455
|
+
if (this._activeProvider === 'deepgram') {
|
|
456
|
+
DeepgramProvider.stop();
|
|
457
|
+
} else if (this._activeProvider === 'webspeech') {
|
|
458
|
+
try {
|
|
459
|
+
this.recognition?.stop();
|
|
460
|
+
} catch (_e) {
|
|
461
|
+
// Already stopped — ignore
|
|
462
|
+
}
|
|
463
|
+
// Stop the mic stream we opened for the level meter
|
|
464
|
+
if (this._webSpeechStream) {
|
|
465
|
+
this._webSpeechStream.getTracks().forEach(t => t.stop());
|
|
466
|
+
this._webSpeechStream = null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this._activeProvider = null;
|
|
470
|
+
|
|
471
|
+
// Haptic feedback on mobile
|
|
472
|
+
if (navigator.vibrate) navigator.vibrate([30, 50, 30]);
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
_onWebSpeechResult(event) {
|
|
476
|
+
if (!this.isRecording) return;
|
|
477
|
+
this._hasReceivedResult = true;
|
|
478
|
+
this._resetSilenceTimeout();
|
|
479
|
+
let interim = '';
|
|
480
|
+
let finalText = '';
|
|
481
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
482
|
+
const transcript = event.results[i][0].transcript;
|
|
483
|
+
if (event.results[i].isFinal) {
|
|
484
|
+
finalText += transcript;
|
|
485
|
+
} else {
|
|
486
|
+
interim += transcript;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (finalText) {
|
|
491
|
+
this._accumulatedFinal += finalText;
|
|
492
|
+
this._hidePreview();
|
|
493
|
+
this._insertText(this._accumulatedFinal);
|
|
494
|
+
this.stop();
|
|
495
|
+
} else if (interim) {
|
|
496
|
+
const display = this._accumulatedFinal + interim;
|
|
497
|
+
this._showPreview(display);
|
|
498
|
+
// iOS Safari workaround: isFinal is always false.
|
|
499
|
+
// Detect when interim results stop changing for 750ms → treat as final.
|
|
500
|
+
this._iosStabilityCheck(interim);
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
_onWebSpeechError(event) {
|
|
505
|
+
// During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them
|
|
506
|
+
if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return;
|
|
507
|
+
|
|
508
|
+
const wasRecording = this.isRecording;
|
|
509
|
+
this.stop();
|
|
510
|
+
if (!wasRecording) return;
|
|
511
|
+
|
|
512
|
+
switch (event.error) {
|
|
513
|
+
case 'not-allowed':
|
|
514
|
+
app.showToast('Microphone access denied. Check browser settings.', 'error');
|
|
515
|
+
break;
|
|
516
|
+
case 'no-speech':
|
|
517
|
+
// Silent — auto-stop is enough feedback
|
|
518
|
+
break;
|
|
519
|
+
case 'network':
|
|
520
|
+
app.showToast('Voice input requires internet connection.', 'error');
|
|
521
|
+
break;
|
|
522
|
+
case 'aborted':
|
|
523
|
+
// User cancelled — no message needed
|
|
524
|
+
break;
|
|
525
|
+
default:
|
|
526
|
+
app.showToast('Voice input error: ' + event.error, 'error');
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
_onWebSpeechEnd() {
|
|
531
|
+
// Recognition ended (browser auto-stopped or we called stop())
|
|
532
|
+
if (!this.isRecording) return;
|
|
533
|
+
|
|
534
|
+
const elapsed = Date.now() - this._recordingStartedAt;
|
|
535
|
+
// Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results).
|
|
536
|
+
// Auto-retry up to 2 times to avoid the "needs two clicks" problem.
|
|
537
|
+
if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) {
|
|
538
|
+
this._retryCount++;
|
|
539
|
+
try {
|
|
540
|
+
this.recognition.start();
|
|
541
|
+
} catch (_e) {
|
|
542
|
+
// If restart fails, fall through to stop
|
|
543
|
+
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
|
|
544
|
+
this.stop();
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Genuine end — finalize any accumulated text
|
|
550
|
+
if (this._accumulatedFinal) {
|
|
551
|
+
this._insertText(this._accumulatedFinal);
|
|
552
|
+
}
|
|
553
|
+
this.stop();
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
_insertText(text) {
|
|
557
|
+
if (!app.activeSessionId || !text.trim()) return;
|
|
558
|
+
const trimmed = text.trim();
|
|
559
|
+
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
|
560
|
+
|
|
561
|
+
if (mode === 'compose') {
|
|
562
|
+
// If a compose overlay is already open, populate its textarea instead of recreating
|
|
563
|
+
const existingTextarea = document.querySelector('.voice-compose-overlay .paste-textarea');
|
|
564
|
+
if (existingTextarea) {
|
|
565
|
+
existingTextarea.value = trimmed;
|
|
566
|
+
existingTextarea.focus();
|
|
567
|
+
existingTextarea.selectionStart = existingTextarea.selectionEnd = trimmed.length;
|
|
568
|
+
} else {
|
|
569
|
+
this._showComposeOverlay(trimmed);
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
// Direct mode: inject into local echo overlay if available, else send to PTY
|
|
573
|
+
if (app._localEchoEnabled && app._localEchoOverlay) {
|
|
574
|
+
app._localEchoOverlay.appendText(trimmed);
|
|
575
|
+
} else {
|
|
576
|
+
app.sendInput(trimmed).catch(() => {});
|
|
577
|
+
}
|
|
578
|
+
this._showVoiceSendBtn();
|
|
579
|
+
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
/** Show a green Enter button by transforming the gear icon in-place */
|
|
584
|
+
_showVoiceSendBtn() {
|
|
585
|
+
// Find the gear button (mobile or desktop header)
|
|
586
|
+
const gear = document.querySelector('.btn-settings-mobile') || document.querySelector('.btn-settings');
|
|
587
|
+
if (!gear || gear.classList.contains('voice-send-active')) return;
|
|
588
|
+
|
|
589
|
+
// Remove existing if any
|
|
590
|
+
this._hideVoiceSendBtn();
|
|
591
|
+
|
|
592
|
+
// Save original state
|
|
593
|
+
this._voiceSendGear = gear;
|
|
594
|
+
this._voiceSendOriginalHTML = gear.innerHTML;
|
|
595
|
+
this._voiceSendOriginalOnclick = gear.getAttribute('onclick');
|
|
596
|
+
|
|
597
|
+
// Transform into green send button
|
|
598
|
+
gear.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
|
599
|
+
gear.classList.add('voice-send-active');
|
|
600
|
+
gear.removeAttribute('onclick');
|
|
601
|
+
gear.title = 'Send (Enter)';
|
|
602
|
+
|
|
603
|
+
// Click handler
|
|
604
|
+
this._voiceSendHandler = () => {
|
|
605
|
+
if (!app.activeSessionId) return;
|
|
606
|
+
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
|
607
|
+
// otherwise just send \r directly to the PTY
|
|
608
|
+
if (app._localEchoEnabled && app._localEchoOverlay) {
|
|
609
|
+
const text = app._localEchoOverlay.pendingText || '';
|
|
610
|
+
app._localEchoOverlay.clear();
|
|
611
|
+
app._localEchoOverlay.suppressBufferDetection();
|
|
612
|
+
if (text) app.sendInput(text).catch(() => {});
|
|
613
|
+
setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
|
|
614
|
+
} else {
|
|
615
|
+
app.sendInput('\r').catch(() => {});
|
|
616
|
+
}
|
|
617
|
+
// Blink then restore
|
|
618
|
+
gear.classList.add('voice-send-blink');
|
|
619
|
+
setTimeout(() => this._hideVoiceSendBtn(), 400);
|
|
620
|
+
};
|
|
621
|
+
gear.addEventListener('click', this._voiceSendHandler);
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
_hideVoiceSendBtn() {
|
|
625
|
+
const gear = this._voiceSendGear;
|
|
626
|
+
if (!gear) return;
|
|
627
|
+
gear.removeEventListener('click', this._voiceSendHandler);
|
|
628
|
+
gear.classList.remove('voice-send-active', 'voice-send-blink');
|
|
629
|
+
gear.innerHTML = this._voiceSendOriginalHTML || '';
|
|
630
|
+
if (this._voiceSendOriginalOnclick) {
|
|
631
|
+
gear.setAttribute('onclick', this._voiceSendOriginalOnclick);
|
|
632
|
+
}
|
|
633
|
+
gear.title = 'App Settings';
|
|
634
|
+
this._voiceSendGear = null;
|
|
635
|
+
this._voiceSendHandler = null;
|
|
636
|
+
this._voiceSendOriginalHTML = null;
|
|
637
|
+
this._voiceSendOriginalOnclick = null;
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
/** Show an editable compose overlay so the user can review/edit before sending */
|
|
641
|
+
_showComposeOverlay(text) {
|
|
642
|
+
document.querySelector('.voice-compose-overlay')?.remove();
|
|
643
|
+
const overlay = document.createElement('div');
|
|
644
|
+
overlay.className = 'voice-compose-overlay paste-overlay';
|
|
645
|
+
overlay.innerHTML = `
|
|
646
|
+
<div class="paste-dialog">
|
|
647
|
+
<textarea class="paste-textarea">${text.replace(/</g, '<')}</textarea>
|
|
648
|
+
<div class="paste-actions">
|
|
649
|
+
<button class="paste-cancel">Cancel</button>
|
|
650
|
+
<button class="paste-new"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> New</button>
|
|
651
|
+
<button class="paste-send">Send</button>
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
`;
|
|
655
|
+
const textarea = overlay.querySelector('textarea');
|
|
656
|
+
const send = () => {
|
|
657
|
+
const val = textarea.value.trim();
|
|
658
|
+
overlay.remove();
|
|
659
|
+
if (val) app.sendInput(val + '\r').catch(() => {});
|
|
660
|
+
};
|
|
661
|
+
const cancel = () => overlay.remove();
|
|
662
|
+
const newInput = () => {
|
|
663
|
+
textarea.value = '';
|
|
664
|
+
textarea.blur();
|
|
665
|
+
this.start();
|
|
666
|
+
};
|
|
667
|
+
overlay.querySelector('.paste-cancel').addEventListener('click', cancel);
|
|
668
|
+
overlay.querySelector('.paste-new').addEventListener('click', newInput);
|
|
669
|
+
overlay.querySelector('.paste-send').addEventListener('click', send);
|
|
670
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); });
|
|
671
|
+
document.body.appendChild(overlay);
|
|
672
|
+
textarea.focus();
|
|
673
|
+
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
_resetSilenceTimeout() {
|
|
677
|
+
clearTimeout(this.silenceTimeout);
|
|
678
|
+
this.silenceTimeout = setTimeout(() => {
|
|
679
|
+
if (this.isRecording) {
|
|
680
|
+
// Finalize any accumulated text before stopping
|
|
681
|
+
if (this._accumulatedFinal) {
|
|
682
|
+
this._insertText(this._accumulatedFinal);
|
|
683
|
+
}
|
|
684
|
+
this.stop();
|
|
685
|
+
}
|
|
686
|
+
}, 3000);
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
_iosStabilityCheck(transcript) {
|
|
690
|
+
if (transcript !== this._lastTranscript) {
|
|
691
|
+
this._lastTranscript = transcript;
|
|
692
|
+
clearTimeout(this._stabilityTimer);
|
|
693
|
+
this._stabilityTimer = setTimeout(() => {
|
|
694
|
+
if (this.isRecording) {
|
|
695
|
+
const finalText = this._accumulatedFinal + transcript;
|
|
696
|
+
this._hidePreview();
|
|
697
|
+
this._insertText(finalText);
|
|
698
|
+
this.stop();
|
|
699
|
+
}
|
|
700
|
+
}, 750);
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
_startDurationTimer() {
|
|
705
|
+
this._stopDurationTimer();
|
|
706
|
+
this._durationInterval = setInterval(() => {
|
|
707
|
+
if (!this.isRecording || !this.previewEl) return;
|
|
708
|
+
const elapsed = Math.floor((Date.now() - this._recordingStartedAt) / 1000);
|
|
709
|
+
const mins = Math.floor(elapsed / 60);
|
|
710
|
+
const secs = elapsed % 60;
|
|
711
|
+
const timeStr = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`;
|
|
712
|
+
const timerEl = this.previewEl.querySelector('.voice-timer');
|
|
713
|
+
if (timerEl) timerEl.textContent = timeStr;
|
|
714
|
+
}, 1000);
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
_stopDurationTimer() {
|
|
718
|
+
if (this._durationInterval) {
|
|
719
|
+
clearInterval(this._durationInterval);
|
|
720
|
+
this._durationInterval = null;
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
/** Start audio level meter using AnalyserNode — attaches to the active mic stream */
|
|
725
|
+
_startLevelMeter(stream) {
|
|
726
|
+
this._stopLevelMeter();
|
|
727
|
+
try {
|
|
728
|
+
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
729
|
+
this._analyserSource = this._audioContext.createMediaStreamSource(stream);
|
|
730
|
+
this._analyser = this._audioContext.createAnalyser();
|
|
731
|
+
this._analyser.fftSize = 256;
|
|
732
|
+
this._analyserSource.connect(this._analyser);
|
|
733
|
+
this._drawLevelMeter();
|
|
734
|
+
} catch (_e) {
|
|
735
|
+
// AudioContext not available — level meter just won't show
|
|
736
|
+
}
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
_stopLevelMeter() {
|
|
740
|
+
if (this._levelAnimFrame) {
|
|
741
|
+
cancelAnimationFrame(this._levelAnimFrame);
|
|
742
|
+
this._levelAnimFrame = null;
|
|
743
|
+
}
|
|
744
|
+
if (this._analyserSource) {
|
|
745
|
+
try { this._analyserSource.disconnect(); } catch (_e) { /* */ }
|
|
746
|
+
this._analyserSource = null;
|
|
747
|
+
}
|
|
748
|
+
if (this._audioContext) {
|
|
749
|
+
try { this._audioContext.close(); } catch (_e) { /* */ }
|
|
750
|
+
this._audioContext = null;
|
|
751
|
+
}
|
|
752
|
+
this._analyser = null;
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
_drawLevelMeter() {
|
|
756
|
+
if (!this._analyser || !this.isRecording) return;
|
|
757
|
+
const dataArray = new Uint8Array(this._analyser.frequencyBinCount);
|
|
758
|
+
this._analyser.getByteFrequencyData(dataArray);
|
|
759
|
+
// Compute RMS level 0-1
|
|
760
|
+
let sum = 0;
|
|
761
|
+
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
|
|
762
|
+
const rms = Math.sqrt(sum / dataArray.length) / 255;
|
|
763
|
+
// Update the level bars in the preview
|
|
764
|
+
const barsEl = this.previewEl?.querySelector('.voice-level-bars');
|
|
765
|
+
if (barsEl) {
|
|
766
|
+
const bars = barsEl.children;
|
|
767
|
+
for (let i = 0; i < bars.length; i++) {
|
|
768
|
+
const threshold = (i + 1) / bars.length;
|
|
769
|
+
bars[i].classList.toggle('active', rms >= threshold * 0.7);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
this._levelAnimFrame = requestAnimationFrame(() => this._drawLevelMeter());
|
|
773
|
+
},
|
|
774
|
+
|
|
775
|
+
_showPreview(text, provider) {
|
|
776
|
+
if (!this.previewEl) {
|
|
777
|
+
this.previewEl = document.createElement('div');
|
|
778
|
+
this.previewEl.className = 'voice-preview';
|
|
779
|
+
this.previewEl.setAttribute('aria-live', 'polite');
|
|
780
|
+
document.body.appendChild(this.previewEl);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Build the indicator structure once, then just update the text node
|
|
784
|
+
if (!this.previewEl.querySelector('.voice-recording-indicator')) {
|
|
785
|
+
this.previewEl.textContent = '';
|
|
786
|
+
// Recording indicator: red dot + level bars + timer
|
|
787
|
+
const indicator = document.createElement('span');
|
|
788
|
+
indicator.className = 'voice-recording-indicator';
|
|
789
|
+
indicator.innerHTML = '<span class="voice-rec-dot"></span>';
|
|
790
|
+
const barsEl = document.createElement('span');
|
|
791
|
+
barsEl.className = 'voice-level-bars';
|
|
792
|
+
for (let i = 0; i < 5; i++) {
|
|
793
|
+
const bar = document.createElement('span');
|
|
794
|
+
bar.className = 'voice-level-bar';
|
|
795
|
+
barsEl.appendChild(bar);
|
|
796
|
+
}
|
|
797
|
+
indicator.appendChild(barsEl);
|
|
798
|
+
const timerEl = document.createElement('span');
|
|
799
|
+
timerEl.className = 'voice-timer';
|
|
800
|
+
timerEl.textContent = '0:00';
|
|
801
|
+
indicator.appendChild(timerEl);
|
|
802
|
+
this.previewEl.appendChild(indicator);
|
|
803
|
+
// Provider badge for Deepgram
|
|
804
|
+
if (provider === 'deepgram') {
|
|
805
|
+
const badge = document.createElement('span');
|
|
806
|
+
badge.className = 'voice-preview-badge';
|
|
807
|
+
badge.textContent = 'DG';
|
|
808
|
+
this.previewEl.appendChild(badge);
|
|
809
|
+
this.previewEl.appendChild(document.createTextNode(' '));
|
|
810
|
+
}
|
|
811
|
+
// Text node for transcript
|
|
812
|
+
this._previewTextNode = document.createTextNode(text || 'Listening...');
|
|
813
|
+
this.previewEl.appendChild(this._previewTextNode);
|
|
814
|
+
} else {
|
|
815
|
+
// Just update the text content
|
|
816
|
+
if (this._previewTextNode) {
|
|
817
|
+
this._previewTextNode.textContent = text || 'Listening...';
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
this.previewEl.style.display = '';
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
_hidePreview() {
|
|
824
|
+
if (this.previewEl) {
|
|
825
|
+
this.previewEl.style.display = 'none';
|
|
826
|
+
this.previewEl.textContent = '';
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
|
|
830
|
+
_updateButtons(state) {
|
|
831
|
+
const isRecording = state === 'recording';
|
|
832
|
+
// Desktop button
|
|
833
|
+
const desktopBtn = document.getElementById('voiceInputBtn');
|
|
834
|
+
if (desktopBtn) {
|
|
835
|
+
desktopBtn.classList.toggle('recording', isRecording);
|
|
836
|
+
desktopBtn.setAttribute('aria-pressed', String(isRecording));
|
|
837
|
+
desktopBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input');
|
|
838
|
+
desktopBtn.title = isRecording ? 'Stop voice input (Ctrl+Shift+V)' : 'Voice input (Ctrl+Shift+V)';
|
|
839
|
+
}
|
|
840
|
+
// Mobile toolbar button (always visible on mobile)
|
|
841
|
+
const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile');
|
|
842
|
+
if (mobileToolbarBtn) {
|
|
843
|
+
mobileToolbarBtn.classList.toggle('recording', isRecording);
|
|
844
|
+
mobileToolbarBtn.setAttribute('aria-pressed', String(isRecording));
|
|
845
|
+
mobileToolbarBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input');
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
|
|
849
|
+
_showButtons() {
|
|
850
|
+
const desktopBtn = document.getElementById('voiceInputBtn');
|
|
851
|
+
if (desktopBtn) desktopBtn.style.display = '';
|
|
852
|
+
const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile');
|
|
853
|
+
if (mobileToolbarBtn) mobileToolbarBtn.style.display = '';
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
/** Cleanup on SSE reconnect or page unload */
|
|
857
|
+
cleanup() {
|
|
858
|
+
if (this.isRecording) this.stop();
|
|
859
|
+
this._hideVoiceSendBtn();
|
|
860
|
+
DeepgramProvider._cleanup();
|
|
861
|
+
this.recognition = null;
|
|
862
|
+
this._activeProvider = null;
|
|
863
|
+
this._stopDurationTimer();
|
|
864
|
+
this._stopLevelMeter();
|
|
865
|
+
if (this._webSpeechStream) {
|
|
866
|
+
this._webSpeechStream.getTracks().forEach(t => t.stop());
|
|
867
|
+
this._webSpeechStream = null;
|
|
868
|
+
}
|
|
869
|
+
if (this.previewEl) {
|
|
870
|
+
this.previewEl.remove();
|
|
871
|
+
this.previewEl = null;
|
|
872
|
+
}
|
|
873
|
+
clearTimeout(this.silenceTimeout);
|
|
874
|
+
clearTimeout(this._stabilityTimer);
|
|
875
|
+
this.silenceTimeout = null;
|
|
876
|
+
this._stabilityTimer = null;
|
|
877
|
+
this._accumulatedFinal = '';
|
|
878
|
+
this._lastTranscript = '';
|
|
879
|
+
this._retryCount = 0;
|
|
880
|
+
this._hasReceivedResult = false;
|
|
881
|
+
}
|
|
882
|
+
};
|