@agent-native/toolkit 0.5.1 → 0.7.0

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.
Files changed (349) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/dist/composer/AgentComposerFrame.d.ts +19 -0
  16. package/dist/composer/AgentComposerFrame.d.ts.map +1 -0
  17. package/dist/composer/AgentComposerFrame.js +14 -0
  18. package/dist/composer/AgentComposerFrame.js.map +1 -0
  19. package/dist/composer/ComposerPlusMenu.d.ts +17 -0
  20. package/dist/composer/ComposerPlusMenu.d.ts.map +1 -0
  21. package/dist/composer/ComposerPlusMenu.js +501 -0
  22. package/dist/composer/ComposerPlusMenu.js.map +1 -0
  23. package/dist/composer/MentionPopover.d.ts +29 -0
  24. package/dist/composer/MentionPopover.d.ts.map +1 -0
  25. package/dist/composer/MentionPopover.js +180 -0
  26. package/dist/composer/MentionPopover.js.map +1 -0
  27. package/dist/composer/PastedTextChip.d.ts +9 -0
  28. package/dist/composer/PastedTextChip.d.ts.map +1 -0
  29. package/dist/composer/PastedTextChip.js +47 -0
  30. package/dist/composer/PastedTextChip.js.map +1 -0
  31. package/dist/composer/PromptComposer.d.ts +114 -0
  32. package/dist/composer/PromptComposer.d.ts.map +1 -0
  33. package/dist/composer/PromptComposer.js +302 -0
  34. package/dist/composer/PromptComposer.js.map +1 -0
  35. package/dist/composer/PromptComposer.spec.d.ts +2 -0
  36. package/dist/composer/PromptComposer.spec.d.ts.map +1 -0
  37. package/dist/composer/PromptComposer.spec.js +64 -0
  38. package/dist/composer/PromptComposer.spec.js.map +1 -0
  39. package/dist/composer/RealtimeVoiceMode.d.ts +104 -0
  40. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -0
  41. package/dist/composer/RealtimeVoiceMode.js +245 -0
  42. package/dist/composer/RealtimeVoiceMode.js.map +1 -0
  43. package/dist/composer/RealtimeVoiceMode.spec.d.ts +2 -0
  44. package/dist/composer/RealtimeVoiceMode.spec.d.ts.map +1 -0
  45. package/dist/composer/RealtimeVoiceMode.spec.js +439 -0
  46. package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -0
  47. package/dist/composer/TiptapComposer.d.ts +221 -0
  48. package/dist/composer/TiptapComposer.d.ts.map +1 -0
  49. package/dist/composer/TiptapComposer.js +1838 -0
  50. package/dist/composer/TiptapComposer.js.map +1 -0
  51. package/dist/composer/TiptapComposer.spec.d.ts +2 -0
  52. package/dist/composer/TiptapComposer.spec.d.ts.map +1 -0
  53. package/dist/composer/TiptapComposer.spec.js +277 -0
  54. package/dist/composer/TiptapComposer.spec.js.map +1 -0
  55. package/dist/composer/VoiceButton.d.ts +25 -0
  56. package/dist/composer/VoiceButton.d.ts.map +1 -0
  57. package/dist/composer/VoiceButton.js +100 -0
  58. package/dist/composer/VoiceButton.js.map +1 -0
  59. package/dist/composer/VoiceButton.spec.d.ts +2 -0
  60. package/dist/composer/VoiceButton.spec.d.ts.map +1 -0
  61. package/dist/composer/VoiceButton.spec.js +19 -0
  62. package/dist/composer/VoiceButton.spec.js.map +1 -0
  63. package/dist/composer/asset-picker-url.d.ts +16 -0
  64. package/dist/composer/asset-picker-url.d.ts.map +1 -0
  65. package/dist/composer/asset-picker-url.js +64 -0
  66. package/dist/composer/asset-picker-url.js.map +1 -0
  67. package/dist/composer/asset-picker-url.spec.d.ts +2 -0
  68. package/dist/composer/asset-picker-url.spec.d.ts.map +1 -0
  69. package/dist/composer/asset-picker-url.spec.js +23 -0
  70. package/dist/composer/asset-picker-url.spec.js.map +1 -0
  71. package/dist/composer/attachment-accept.d.ts +9 -0
  72. package/dist/composer/attachment-accept.d.ts.map +1 -0
  73. package/dist/composer/attachment-accept.js +65 -0
  74. package/dist/composer/attachment-accept.js.map +1 -0
  75. package/dist/composer/attachment-accept.spec.d.ts +2 -0
  76. package/dist/composer/attachment-accept.spec.d.ts.map +1 -0
  77. package/dist/composer/attachment-accept.spec.js +15 -0
  78. package/dist/composer/attachment-accept.spec.js.map +1 -0
  79. package/dist/composer/draft-key.d.ts +2 -0
  80. package/dist/composer/draft-key.d.ts.map +1 -0
  81. package/dist/composer/draft-key.js +8 -0
  82. package/dist/composer/draft-key.js.map +1 -0
  83. package/dist/composer/draft-key.spec.d.ts +2 -0
  84. package/dist/composer/draft-key.spec.d.ts.map +1 -0
  85. package/dist/composer/draft-key.spec.js +13 -0
  86. package/dist/composer/draft-key.spec.js.map +1 -0
  87. package/dist/composer/extensions/FileReference.d.ts +3 -0
  88. package/dist/composer/extensions/FileReference.d.ts.map +1 -0
  89. package/dist/composer/extensions/FileReference.js +36 -0
  90. package/dist/composer/extensions/FileReference.js.map +1 -0
  91. package/dist/composer/extensions/MentionReference.d.ts +3 -0
  92. package/dist/composer/extensions/MentionReference.d.ts.map +1 -0
  93. package/dist/composer/extensions/MentionReference.js +66 -0
  94. package/dist/composer/extensions/MentionReference.js.map +1 -0
  95. package/dist/composer/extensions/SkillReference.d.ts +3 -0
  96. package/dist/composer/extensions/SkillReference.d.ts.map +1 -0
  97. package/dist/composer/extensions/SkillReference.js +40 -0
  98. package/dist/composer/extensions/SkillReference.js.map +1 -0
  99. package/dist/composer/index.d.ts +14 -0
  100. package/dist/composer/index.d.ts.map +1 -0
  101. package/dist/composer/index.js +13 -0
  102. package/dist/composer/index.js.map +1 -0
  103. package/dist/composer/pasted-text.d.ts +31 -0
  104. package/dist/composer/pasted-text.d.ts.map +1 -0
  105. package/dist/composer/pasted-text.js +130 -0
  106. package/dist/composer/pasted-text.js.map +1 -0
  107. package/dist/composer/pasted-text.spec.d.ts +2 -0
  108. package/dist/composer/pasted-text.spec.d.ts.map +1 -0
  109. package/dist/composer/pasted-text.spec.js +107 -0
  110. package/dist/composer/pasted-text.spec.js.map +1 -0
  111. package/dist/composer/prompt-attachments.d.ts +18 -0
  112. package/dist/composer/prompt-attachments.d.ts.map +1 -0
  113. package/dist/composer/prompt-attachments.js +65 -0
  114. package/dist/composer/prompt-attachments.js.map +1 -0
  115. package/dist/composer/prompt-attachments.spec.d.ts +2 -0
  116. package/dist/composer/prompt-attachments.spec.d.ts.map +1 -0
  117. package/dist/composer/prompt-attachments.spec.js +63 -0
  118. package/dist/composer/prompt-attachments.spec.js.map +1 -0
  119. package/dist/composer/realtime-voice-audio-level.d.ts +14 -0
  120. package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -0
  121. package/dist/composer/realtime-voice-audio-level.js +47 -0
  122. package/dist/composer/realtime-voice-audio-level.js.map +1 -0
  123. package/dist/composer/realtime-voice-audio-level.spec.d.ts +2 -0
  124. package/dist/composer/realtime-voice-audio-level.spec.d.ts.map +1 -0
  125. package/dist/composer/realtime-voice-audio-level.spec.js +25 -0
  126. package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -0
  127. package/dist/composer/realtime-voice-transcript.d.ts +26 -0
  128. package/dist/composer/realtime-voice-transcript.d.ts.map +1 -0
  129. package/dist/composer/realtime-voice-transcript.js +103 -0
  130. package/dist/composer/realtime-voice-transcript.js.map +1 -0
  131. package/dist/composer/realtime-voice-transcript.spec.d.ts +2 -0
  132. package/dist/composer/realtime-voice-transcript.spec.d.ts.map +1 -0
  133. package/dist/composer/realtime-voice-transcript.spec.js +76 -0
  134. package/dist/composer/realtime-voice-transcript.spec.js.map +1 -0
  135. package/dist/composer/runtime-adapters.d.ts +163 -0
  136. package/dist/composer/runtime-adapters.d.ts.map +1 -0
  137. package/dist/composer/runtime-adapters.js +130 -0
  138. package/dist/composer/runtime-adapters.js.map +1 -0
  139. package/dist/composer/types.d.ts +60 -0
  140. package/dist/composer/types.d.ts.map +1 -0
  141. package/dist/composer/types.js +2 -0
  142. package/dist/composer/types.js.map +1 -0
  143. package/dist/composer/use-file-search.d.ts +6 -0
  144. package/dist/composer/use-file-search.d.ts.map +1 -0
  145. package/dist/composer/use-file-search.js +53 -0
  146. package/dist/composer/use-file-search.js.map +1 -0
  147. package/dist/composer/use-mention-search.d.ts +6 -0
  148. package/dist/composer/use-mention-search.d.ts.map +1 -0
  149. package/dist/composer/use-mention-search.js +75 -0
  150. package/dist/composer/use-mention-search.js.map +1 -0
  151. package/dist/composer/use-skills.d.ts +7 -0
  152. package/dist/composer/use-skills.d.ts.map +1 -0
  153. package/dist/composer/use-skills.js +40 -0
  154. package/dist/composer/use-skills.js.map +1 -0
  155. package/dist/composer/useRealtimeVoiceMode.d.ts +179 -0
  156. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -0
  157. package/dist/composer/useRealtimeVoiceMode.js +1657 -0
  158. package/dist/composer/useRealtimeVoiceMode.js.map +1 -0
  159. package/dist/composer/useRealtimeVoiceMode.spec.d.ts +2 -0
  160. package/dist/composer/useRealtimeVoiceMode.spec.d.ts.map +1 -0
  161. package/dist/composer/useRealtimeVoiceMode.spec.js +618 -0
  162. package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -0
  163. package/dist/composer/useVoiceDictation.d.ts +57 -0
  164. package/dist/composer/useVoiceDictation.d.ts.map +1 -0
  165. package/dist/composer/useVoiceDictation.js +1004 -0
  166. package/dist/composer/useVoiceDictation.js.map +1 -0
  167. package/dist/composer/useVoiceDictation.spec.d.ts +2 -0
  168. package/dist/composer/useVoiceDictation.spec.d.ts.map +1 -0
  169. package/dist/composer/useVoiceDictation.spec.js +32 -0
  170. package/dist/composer/useVoiceDictation.spec.js.map +1 -0
  171. package/dist/editor/RegistryBlockContext.d.ts +68 -0
  172. package/dist/editor/RegistryBlockContext.d.ts.map +1 -0
  173. package/dist/editor/RegistryBlockContext.js +10 -0
  174. package/dist/editor/RegistryBlockContext.js.map +1 -0
  175. package/dist/editor/RegistryBlockNode.d.ts +66 -0
  176. package/dist/editor/RegistryBlockNode.d.ts.map +1 -0
  177. package/dist/editor/RegistryBlockNode.js +406 -0
  178. package/dist/editor/RegistryBlockNode.js.map +1 -0
  179. package/dist/editor/RegistryBlockNode.spec.d.ts +2 -0
  180. package/dist/editor/RegistryBlockNode.spec.d.ts.map +1 -0
  181. package/dist/editor/RegistryBlockNode.spec.js +215 -0
  182. package/dist/editor/RegistryBlockNode.spec.js.map +1 -0
  183. package/dist/editor/index.d.ts +2 -0
  184. package/dist/editor/index.d.ts.map +1 -1
  185. package/dist/editor/index.js +2 -0
  186. package/dist/editor/index.js.map +1 -1
  187. package/dist/editor/registrySlashCommands.d.ts +68 -0
  188. package/dist/editor/registrySlashCommands.d.ts.map +1 -0
  189. package/dist/editor/registrySlashCommands.js +51 -0
  190. package/dist/editor/registrySlashCommands.js.map +1 -0
  191. package/package.json +32 -1
  192. package/src/app-shell/header-actions.tsx +84 -0
  193. package/src/app-shell/index.ts +1 -0
  194. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  195. package/src/chat-history/ChatHistoryList.tsx +469 -0
  196. package/src/chat-history/index.ts +7 -0
  197. package/src/chat-history.css +314 -0
  198. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  199. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  200. package/src/collab-ui/PresenceBar.tsx +346 -0
  201. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  202. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  203. package/src/collab-ui/agent-identity.ts +16 -0
  204. package/src/collab-ui/index.ts +39 -0
  205. package/src/collab-ui/lead-client.ts +32 -0
  206. package/src/collab-ui/types.spec.ts +18 -0
  207. package/src/collab-ui/types.ts +96 -0
  208. package/src/composer/AgentComposerFrame.tsx +59 -0
  209. package/src/composer/ComposerPlusMenu.tsx +983 -0
  210. package/src/composer/MentionPopover.tsx +412 -0
  211. package/src/composer/PastedTextChip.tsx +150 -0
  212. package/src/composer/PromptComposer.spec.ts +73 -0
  213. package/src/composer/PromptComposer.tsx +718 -0
  214. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  215. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  216. package/src/composer/TiptapComposer.spec.ts +390 -0
  217. package/src/composer/TiptapComposer.tsx +2936 -0
  218. package/src/composer/VoiceButton.spec.ts +33 -0
  219. package/src/composer/VoiceButton.tsx +255 -0
  220. package/src/composer/asset-picker-url.spec.ts +56 -0
  221. package/src/composer/asset-picker-url.ts +80 -0
  222. package/src/composer/attachment-accept.spec.ts +22 -0
  223. package/src/composer/attachment-accept.ts +71 -0
  224. package/src/composer/draft-key.spec.ts +19 -0
  225. package/src/composer/draft-key.ts +7 -0
  226. package/src/composer/extensions/FileReference.tsx +55 -0
  227. package/src/composer/extensions/MentionReference.tsx +93 -0
  228. package/src/composer/extensions/SkillReference.tsx +56 -0
  229. package/src/composer/index.ts +79 -0
  230. package/src/composer/pasted-text.spec.ts +162 -0
  231. package/src/composer/pasted-text.ts +160 -0
  232. package/src/composer/prompt-attachments.spec.ts +80 -0
  233. package/src/composer/prompt-attachments.ts +93 -0
  234. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  235. package/src/composer/realtime-voice-audio-level.ts +64 -0
  236. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  237. package/src/composer/realtime-voice-transcript.ts +134 -0
  238. package/src/composer/runtime-adapters.tsx +343 -0
  239. package/src/composer/types.ts +66 -0
  240. package/src/composer/use-file-search.ts +64 -0
  241. package/src/composer/use-mention-search.ts +90 -0
  242. package/src/composer/use-skills.ts +45 -0
  243. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  244. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  245. package/src/composer/useVoiceDictation.spec.ts +45 -0
  246. package/src/composer/useVoiceDictation.ts +1191 -0
  247. package/src/context-ui/ContextMeter.tsx +101 -0
  248. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  249. package/src/context-ui/ContextTreemap.tsx +125 -0
  250. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  251. package/src/context-ui/format.ts +42 -0
  252. package/src/context-ui/index.ts +15 -0
  253. package/src/context-ui/types.ts +43 -0
  254. package/src/design-tweaks/index.ts +1 -0
  255. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  256. package/src/editor/BubbleToolbar.tsx +253 -0
  257. package/src/editor/CodeBlockNode.tsx +254 -0
  258. package/src/editor/DragHandle.spec.ts +588 -0
  259. package/src/editor/DragHandle.ts +1437 -0
  260. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  261. package/src/editor/ImageExtension.ts +306 -0
  262. package/src/editor/RegistryBlockContext.tsx +107 -0
  263. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  264. package/src/editor/RegistryBlockNode.tsx +652 -0
  265. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  266. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  267. package/src/editor/RichMarkdownEditor.tsx +81 -0
  268. package/src/editor/RunId.ts +61 -0
  269. package/src/editor/SharedRichEditor.tsx +321 -0
  270. package/src/editor/SlashCommandMenu.tsx +310 -0
  271. package/src/editor/disableHistory.spec.ts +85 -0
  272. package/src/editor/extensions.ts +402 -0
  273. package/src/editor/gfmDoc.spec.ts +230 -0
  274. package/src/editor/gfmDoc.ts +92 -0
  275. package/src/editor/index.ts +93 -0
  276. package/src/editor/registrySlashCommands.ts +131 -0
  277. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  278. package/src/editor/surgical-apply.spec.ts +279 -0
  279. package/src/editor/surgical-apply.ts +192 -0
  280. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  281. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  282. package/src/editor/useCollabReconcile.ts +589 -0
  283. package/src/editor.css +510 -0
  284. package/src/hooks/index.ts +2 -0
  285. package/src/hooks/use-mobile.tsx +21 -0
  286. package/src/hooks/use-toast.ts +188 -0
  287. package/src/index.ts +8 -0
  288. package/src/onboarding/index.ts +1 -0
  289. package/src/provider.tsx +47 -0
  290. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  291. package/src/sharing/VisibilityBadge.tsx +37 -0
  292. package/src/sharing/index.ts +4 -0
  293. package/src/styles.css +21 -0
  294. package/src/ui/accordion.tsx +56 -0
  295. package/src/ui/alert-dialog.tsx +141 -0
  296. package/src/ui/alert.tsx +59 -0
  297. package/src/ui/aspect-ratio.tsx +5 -0
  298. package/src/ui/avatar.tsx +48 -0
  299. package/src/ui/badge.tsx +37 -0
  300. package/src/ui/breadcrumb.tsx +115 -0
  301. package/src/ui/button.tsx +78 -0
  302. package/src/ui/calendar.tsx +214 -0
  303. package/src/ui/card.tsx +86 -0
  304. package/src/ui/carousel.tsx +260 -0
  305. package/src/ui/chart.tsx +375 -0
  306. package/src/ui/checkbox.tsx +28 -0
  307. package/src/ui/collapsible.tsx +9 -0
  308. package/src/ui/command.spec.tsx +51 -0
  309. package/src/ui/command.tsx +174 -0
  310. package/src/ui/context-menu.tsx +202 -0
  311. package/src/ui/date-picker.tsx +64 -0
  312. package/src/ui/dialog.spec.tsx +43 -0
  313. package/src/ui/dialog.tsx +146 -0
  314. package/src/ui/drawer.tsx +126 -0
  315. package/src/ui/dropdown-menu.tsx +218 -0
  316. package/src/ui/form.tsx +177 -0
  317. package/src/ui/hover-card.tsx +27 -0
  318. package/src/ui/index.ts +52 -0
  319. package/src/ui/input-otp.tsx +69 -0
  320. package/src/ui/input.tsx +22 -0
  321. package/src/ui/label.tsx +24 -0
  322. package/src/ui/logo.tsx +36 -0
  323. package/src/ui/menubar.tsx +235 -0
  324. package/src/ui/navigation-menu.tsx +128 -0
  325. package/src/ui/pagination.tsx +121 -0
  326. package/src/ui/popover.tsx +44 -0
  327. package/src/ui/progress.tsx +26 -0
  328. package/src/ui/radio-group.tsx +42 -0
  329. package/src/ui/resizable.tsx +43 -0
  330. package/src/ui/scroll-area.tsx +46 -0
  331. package/src/ui/select.tsx +158 -0
  332. package/src/ui/separator.tsx +29 -0
  333. package/src/ui/sheet.tsx +160 -0
  334. package/src/ui/sidebar.tsx +777 -0
  335. package/src/ui/skeleton.tsx +18 -0
  336. package/src/ui/slider.tsx +26 -0
  337. package/src/ui/sonner.tsx +58 -0
  338. package/src/ui/spinner.tsx +17 -0
  339. package/src/ui/switch.tsx +27 -0
  340. package/src/ui/table.tsx +117 -0
  341. package/src/ui/tabs.tsx +53 -0
  342. package/src/ui/textarea.tsx +23 -0
  343. package/src/ui/toast.tsx +127 -0
  344. package/src/ui/toaster.tsx +33 -0
  345. package/src/ui/toggle-group.tsx +59 -0
  346. package/src/ui/toggle.tsx +43 -0
  347. package/src/ui/tooltip.tsx +53 -0
  348. package/src/ui/use-toast.ts +3 -0
  349. package/src/utils.ts +8 -0
@@ -0,0 +1,1191 @@
1
+ /**
2
+ * Voice dictation hook for the agent composer.
3
+ *
4
+ * Wires voice providers behind a single state machine:
5
+ * - "auto" / "openai" / "builder" / "builder-gemini" / "gemini" / "groq"
6
+ * — MediaRecorder → POST /_agent-native/transcribe-voice
7
+ * - "google-realtime"
8
+ * — MediaRecorder chunks → POST /_agent-native/transcribe-stream/session
9
+ * → managed WebSocket → Google Speech-to-Text streaming
10
+ * - "browser" — Web Speech API (low quality, offline capable)
11
+ *
12
+ * Provider preference lives in application_state under
13
+ * `voice-transcription-prefs` (`{ transcriptionMode, provider, instructions }`).
14
+ * The composer reads it on every start so settings changes take effect
15
+ * immediately without unmounting the composer.
16
+ *
17
+ * The hook exposes amplitude (0..1) and duration (ms) so the composer can
18
+ * render the Lovable-style live waveform + MM:SS timer.
19
+ */
20
+
21
+ import { useCallback, useEffect, useRef, useState } from "react";
22
+
23
+ import {
24
+ applyVoiceContextReplacements,
25
+ type VoiceContextPack,
26
+ useComposerRuntimeAdapters,
27
+ } from "./runtime-adapters.js";
28
+
29
+ export type VoiceProvider =
30
+ | "auto"
31
+ | "openai"
32
+ | "browser"
33
+ | "google-realtime"
34
+ | "builder-gemini"
35
+ | "builder"
36
+ | "gemini"
37
+ | "groq";
38
+
39
+ export type TranscriptionMode = "mac-native" | "google-realtime" | "batch";
40
+
41
+ export interface VoicePrefs {
42
+ provider: VoiceProvider;
43
+ transcriptionMode?: TranscriptionMode;
44
+ instructions?: string;
45
+ cleanupEnabled?: boolean;
46
+ }
47
+
48
+ export interface VoiceCleanupPrefs {
49
+ enabled?: boolean;
50
+ }
51
+
52
+ type VoiceContextPackSource =
53
+ | VoiceContextPack
54
+ | (() => VoiceContextPack | undefined | Promise<VoiceContextPack | undefined>)
55
+ | undefined;
56
+
57
+ const PREFS_KEY = "voice-transcription-prefs";
58
+ const CLEANUP_PREFS_KEY = "voice-cleanup-prefs";
59
+ const PREFS_URL = `/_agent-native/application-state/${PREFS_KEY}`;
60
+ const CLEANUP_PREFS_URL = `/_agent-native/application-state/${CLEANUP_PREFS_KEY}`;
61
+ const TRANSCRIBE_URL = "/_agent-native/transcribe-voice";
62
+ const GOOGLE_REALTIME_SESSION_URL = "/_agent-native/transcribe-stream/session";
63
+ const GOOGLE_REALTIME_WS_PROTOCOL = "google-realtime.v1";
64
+ function isVoiceProvider(value: unknown): value is VoiceProvider {
65
+ return (
66
+ value === "auto" ||
67
+ value === "openai" ||
68
+ value === "browser" ||
69
+ value === "google-realtime" ||
70
+ value === "builder-gemini" ||
71
+ value === "builder" ||
72
+ value === "gemini" ||
73
+ value === "groq"
74
+ );
75
+ }
76
+
77
+ function isTranscriptionMode(value: unknown): value is TranscriptionMode {
78
+ return (
79
+ value === "mac-native" || value === "google-realtime" || value === "batch"
80
+ );
81
+ }
82
+
83
+ async function defaultProvider(): Promise<VoiceProvider> {
84
+ return "auto";
85
+ }
86
+
87
+ function normalizeProviderForMode(
88
+ mode: TranscriptionMode | undefined,
89
+ provider: VoiceProvider | null,
90
+ ): VoiceProvider | null {
91
+ if (mode === "mac-native") return "browser";
92
+ if (mode === "google-realtime") return "google-realtime";
93
+ if (mode === "batch") {
94
+ if (!provider || provider === "browser") return "auto";
95
+ return provider === "builder" ? "builder-gemini" : provider;
96
+ }
97
+ if (!provider) return null;
98
+ return provider === "builder" ? "builder-gemini" : provider;
99
+ }
100
+
101
+ export type VoiceState =
102
+ | "idle"
103
+ | "starting"
104
+ | "recording"
105
+ | "transcribing"
106
+ | "error";
107
+
108
+ export interface UseVoiceDictationOptions {
109
+ onTranscript: (text: string) => void;
110
+ onError?: (message: string) => void;
111
+ /** Called with (accumulatedFinalText, currentInterimText) as speech is recognized in real time. */
112
+ onLiveUpdate?: (finalText: string, interimText: string) => void;
113
+ contextPack?: VoiceContextPackSource;
114
+ }
115
+
116
+ export interface VoiceDictationApi {
117
+ state: VoiceState;
118
+ amplitude: number;
119
+ durationMs: number;
120
+ errorMessage: string | null;
121
+ provider: VoiceProvider;
122
+ supported: boolean;
123
+ start: () => Promise<void>;
124
+ stop: () => void;
125
+ cancel: () => void;
126
+ dismissError: () => void;
127
+ }
128
+
129
+ async function readVoicePrefs(
130
+ resolvePath: (path: string) => string,
131
+ ): Promise<VoicePrefs> {
132
+ const cleanupPrefs = await readVoiceCleanupPrefs(resolvePath);
133
+ try {
134
+ const res = await fetch(resolvePath(PREFS_URL));
135
+ if (!res.ok) {
136
+ return {
137
+ provider: await defaultProvider(),
138
+ cleanupEnabled: cleanupPrefs.enabled,
139
+ };
140
+ }
141
+ const body = (await res.json()) as
142
+ | VoicePrefs
143
+ | { value?: VoicePrefs }
144
+ | null;
145
+ const value =
146
+ (body as { value?: VoicePrefs } | null)?.value ??
147
+ (body as VoicePrefs | null);
148
+ const mode = isTranscriptionMode(value?.transcriptionMode)
149
+ ? value.transcriptionMode
150
+ : undefined;
151
+ const p =
152
+ (body as VoicePrefs | null)?.provider ??
153
+ (body as { value?: VoicePrefs } | null)?.value?.provider;
154
+ const instructions =
155
+ (body as VoicePrefs | null)?.instructions ??
156
+ (body as { value?: VoicePrefs } | null)?.value?.instructions;
157
+ const provider = normalizeProviderForMode(
158
+ mode,
159
+ isVoiceProvider(p) ? p : null,
160
+ );
161
+ if (provider) {
162
+ return {
163
+ transcriptionMode: mode,
164
+ provider,
165
+ cleanupEnabled: cleanupPrefs.enabled,
166
+ instructions:
167
+ typeof instructions === "string" ? instructions.trim() : undefined,
168
+ };
169
+ }
170
+ } catch {
171
+ /* fall through */
172
+ }
173
+ return {
174
+ provider: await defaultProvider(),
175
+ cleanupEnabled: cleanupPrefs.enabled,
176
+ };
177
+ }
178
+
179
+ async function readVoiceCleanupPrefs(
180
+ resolvePath: (path: string) => string,
181
+ ): Promise<VoiceCleanupPrefs> {
182
+ try {
183
+ const res = await fetch(resolvePath(CLEANUP_PREFS_URL));
184
+ if (!res.ok) return {};
185
+ const body = (await res.json()) as
186
+ | VoiceCleanupPrefs
187
+ | { value?: VoiceCleanupPrefs }
188
+ | null;
189
+ const value =
190
+ (body as { value?: VoiceCleanupPrefs } | null)?.value ??
191
+ (body as VoiceCleanupPrefs | null);
192
+ return { enabled: value?.enabled === true };
193
+ } catch {
194
+ return {};
195
+ }
196
+ }
197
+
198
+ function getSpeechRecognitionCtor(): any {
199
+ if (typeof window === "undefined") return null;
200
+ return (
201
+ (window as any).SpeechRecognition ||
202
+ (window as any).webkitSpeechRecognition ||
203
+ null
204
+ );
205
+ }
206
+
207
+ function pickMimeType(): string {
208
+ if (typeof MediaRecorder === "undefined") return "audio/webm";
209
+ const candidates = [
210
+ "audio/webm;codecs=opus",
211
+ "audio/webm",
212
+ "audio/mp4",
213
+ "audio/ogg;codecs=opus",
214
+ ];
215
+ for (const mime of candidates) {
216
+ try {
217
+ if (MediaRecorder.isTypeSupported(mime)) return mime;
218
+ } catch {
219
+ /* ignore */
220
+ }
221
+ }
222
+ return "audio/webm";
223
+ }
224
+
225
+ async function resolveVoiceContextPack(
226
+ source: VoiceContextPackSource,
227
+ ): Promise<VoiceContextPack | undefined> {
228
+ try {
229
+ const value = typeof source === "function" ? await source() : source;
230
+ return value && typeof value === "object" ? value : undefined;
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ }
235
+
236
+ function appendVoiceContext(
237
+ form: FormData,
238
+ contextPack: VoiceContextPack | undefined,
239
+ ): void {
240
+ if (!contextPack) return;
241
+ try {
242
+ form.append("voiceContext", JSON.stringify(contextPack));
243
+ } catch {
244
+ /* best effort */
245
+ }
246
+ }
247
+
248
+ export function providerForTextCleanup(provider: VoiceProvider): VoiceProvider {
249
+ return provider === "google-realtime" ? "auto" : provider;
250
+ }
251
+
252
+ async function cleanupRecognizedText({
253
+ text,
254
+ provider,
255
+ instructions,
256
+ contextPack,
257
+ resolvePath,
258
+ replaceContext,
259
+ }: {
260
+ text: string;
261
+ provider: VoiceProvider;
262
+ instructions?: string;
263
+ contextPack?: VoiceContextPack;
264
+ resolvePath: (path: string) => string;
265
+ replaceContext: (
266
+ text: string,
267
+ context: VoiceContextPack | undefined,
268
+ ) => string;
269
+ }): Promise<string> {
270
+ if (provider === "browser") {
271
+ return replaceContext(text, contextPack);
272
+ }
273
+
274
+ const form = new FormData();
275
+ form.append("text", text);
276
+ form.append("provider", providerForTextCleanup(provider));
277
+ if (instructions?.trim()) {
278
+ form.append("instructions", instructions.trim());
279
+ }
280
+ appendVoiceContext(form, contextPack);
281
+
282
+ const res = await fetch(resolvePath(TRANSCRIBE_URL), {
283
+ method: "POST",
284
+ body: form,
285
+ });
286
+ if (!res.ok) {
287
+ const body = await res
288
+ .json()
289
+ .catch(() => ({ error: `HTTP ${res.status}` }));
290
+ throw new Error(body.error || `Transcript cleanup failed (${res.status})`);
291
+ }
292
+ const data = (await res.json()) as { text?: string };
293
+ return (data.text ?? "").trim() || text;
294
+ }
295
+
296
+ type BrowserDocumentPolicy = {
297
+ allowsFeature: (feature: string) => boolean;
298
+ };
299
+
300
+ function microphoneBlockedByPermissionsPolicy(): boolean {
301
+ if (typeof document === "undefined") return false;
302
+ const doc = document as Document & {
303
+ permissionsPolicy?: BrowserDocumentPolicy;
304
+ featurePolicy?: BrowserDocumentPolicy;
305
+ };
306
+ const policy = doc.permissionsPolicy ?? doc.featurePolicy ?? null;
307
+ if (!policy?.allowsFeature) return false;
308
+ try {
309
+ return !policy.allowsFeature("microphone");
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ export function voiceDictationStartErrorMessage(error: unknown): string {
316
+ const name =
317
+ typeof (error as { name?: unknown } | null)?.name === "string"
318
+ ? (error as { name: string }).name
319
+ : "";
320
+ const message =
321
+ typeof (error as { message?: unknown } | null)?.message === "string"
322
+ ? (error as { message: string }).message
323
+ : "";
324
+
325
+ if (
326
+ microphoneBlockedByPermissionsPolicy() ||
327
+ /permissions[- ]policy|feature policy/i.test(message)
328
+ ) {
329
+ return "This app is blocking microphone access through its browser permissions policy. Reload the app, or open it directly in a browser tab.";
330
+ }
331
+
332
+ if (
333
+ name === "NotAllowedError" ||
334
+ name === "SecurityError" ||
335
+ /permission|denied|not allowed/i.test(message)
336
+ ) {
337
+ return "Microphone access is blocked. Click the site controls icon in the address bar, set Microphone to Allow for this app, then try again.";
338
+ }
339
+
340
+ if (name === "NotFoundError" || name === "DevicesNotFoundError") {
341
+ return "No microphone was found. Plug one in or choose a different input, then try again.";
342
+ }
343
+
344
+ if (name === "NotReadableError" || name === "TrackStartError") {
345
+ return "Your microphone is busy in another app. Close the other app or choose a different input, then try again.";
346
+ }
347
+
348
+ return message || "Could not start recording";
349
+ }
350
+
351
+ function voiceDictationSpeechErrorMessage(error: string | undefined): string {
352
+ if (error === "not-allowed" || error === "service-not-allowed") {
353
+ return voiceDictationStartErrorMessage({
354
+ name: "NotAllowedError",
355
+ message: error,
356
+ });
357
+ }
358
+ if (error === "audio-capture") {
359
+ return "No microphone was found. Plug one in or choose a different input, then try again.";
360
+ }
361
+ return `Speech recognition error: ${error ?? "unknown"}`;
362
+ }
363
+
364
+ export function useVoiceDictation(
365
+ options: UseVoiceDictationOptions,
366
+ ): VoiceDictationApi {
367
+ const adapters = useComposerRuntimeAdapters();
368
+ const resolvePath = adapters.resolvePath!;
369
+ const replaceContext =
370
+ adapters.voice!.applyContextReplacements ?? applyVoiceContextReplacements;
371
+ const { onTranscript, onError, onLiveUpdate, contextPack } = options;
372
+ const onTranscriptRef = useRef(onTranscript);
373
+ const onErrorRef = useRef(onError);
374
+ const onLiveUpdateRef = useRef(onLiveUpdate);
375
+ const contextPackRef = useRef(contextPack);
376
+ onTranscriptRef.current = onTranscript;
377
+ onErrorRef.current = onError;
378
+ onLiveUpdateRef.current = onLiveUpdate;
379
+ contextPackRef.current = contextPack;
380
+
381
+ const [state, setState] = useState<VoiceState>("idle");
382
+ const [amplitude, setAmplitude] = useState(0);
383
+ const [durationMs, setDurationMs] = useState(0);
384
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
385
+ const [provider, setProvider] = useState<VoiceProvider>("auto");
386
+
387
+ // Keep refs for teardown / cross-branch access.
388
+ const mediaStreamRef = useRef<MediaStream | null>(null);
389
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
390
+ const chunksRef = useRef<Blob[]>([]);
391
+ const audioContextRef = useRef<AudioContext | null>(null);
392
+ const analyserRef = useRef<AnalyserNode | null>(null);
393
+ const rafRef = useRef<number | null>(null);
394
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
395
+ const startedAtRef = useRef<number>(0);
396
+ const cancelledRef = useRef(false);
397
+ const speechRef = useRef<any>(null);
398
+ const speechTranscriptRef = useRef<string>("");
399
+ const activeProviderRef = useRef<VoiceProvider>("browser");
400
+ // Parallel live recognition for OpenAI mode (provides instant preview while MediaRecorder captures)
401
+ const liveSpeechRef = useRef<any>(null);
402
+ const liveTextRef = useRef<string>("");
403
+ const realtimeSocketRef = useRef<WebSocket | null>(null);
404
+ const realtimeFinalRef = useRef<string>("");
405
+ const realtimeInterimRef = useRef<string>("");
406
+ const realtimeStopTimeoutRef = useRef<number | null>(null);
407
+
408
+ const mediaRecorderSupported =
409
+ typeof window !== "undefined" &&
410
+ typeof navigator !== "undefined" &&
411
+ !!navigator.mediaDevices?.getUserMedia &&
412
+ typeof (window as any).MediaRecorder !== "undefined";
413
+ const speechSupported = !!getSpeechRecognitionCtor();
414
+ const supported = mediaRecorderSupported || speechSupported;
415
+
416
+ const teardown = useCallback(() => {
417
+ if (rafRef.current != null) {
418
+ cancelAnimationFrame(rafRef.current);
419
+ rafRef.current = null;
420
+ }
421
+ if (timerRef.current != null) {
422
+ clearInterval(timerRef.current);
423
+ timerRef.current = null;
424
+ }
425
+ if (mediaStreamRef.current) {
426
+ for (const track of mediaStreamRef.current.getTracks()) {
427
+ track.stop();
428
+ }
429
+ mediaStreamRef.current = null;
430
+ }
431
+ if (audioContextRef.current) {
432
+ audioContextRef.current.close().catch(() => {});
433
+ audioContextRef.current = null;
434
+ }
435
+ if (speechRef.current) {
436
+ // Stop the Web Speech session before dropping the ref so the browser
437
+ // releases the mic and stops dispatching onresult events into a stale
438
+ // closure. abort() is fire-and-forget (no final result); stop() would
439
+ // deliver remaining partials but we've already cleared state.
440
+ try {
441
+ speechRef.current.abort?.();
442
+ } catch {
443
+ /* ignore */
444
+ }
445
+ }
446
+ if (liveSpeechRef.current) {
447
+ try {
448
+ liveSpeechRef.current.abort?.();
449
+ } catch {
450
+ /* ignore */
451
+ }
452
+ liveSpeechRef.current = null;
453
+ }
454
+ if (realtimeSocketRef.current) {
455
+ const socket = realtimeSocketRef.current;
456
+ realtimeSocketRef.current = null;
457
+ try {
458
+ socket.close();
459
+ } catch {
460
+ /* ignore */
461
+ }
462
+ }
463
+ if (realtimeStopTimeoutRef.current) {
464
+ clearTimeout(realtimeStopTimeoutRef.current);
465
+ realtimeStopTimeoutRef.current = null;
466
+ }
467
+ analyserRef.current = null;
468
+ mediaRecorderRef.current = null;
469
+ chunksRef.current = [];
470
+ speechRef.current = null;
471
+ speechTranscriptRef.current = "";
472
+ liveTextRef.current = "";
473
+ realtimeFinalRef.current = "";
474
+ realtimeInterimRef.current = "";
475
+ setAmplitude(0);
476
+ }, []);
477
+
478
+ useEffect(() => teardown, [teardown]);
479
+
480
+ const failWith = useCallback(
481
+ (message: string) => {
482
+ setErrorMessage(message);
483
+ setState("error");
484
+ onErrorRef.current?.(message);
485
+ teardown();
486
+ },
487
+ [teardown],
488
+ );
489
+
490
+ const getVoiceContextPack = useCallback(
491
+ () => resolveVoiceContextPack(contextPackRef.current),
492
+ [],
493
+ );
494
+
495
+ const startMeter = useCallback((stream: MediaStream) => {
496
+ try {
497
+ const AudioCtor =
498
+ typeof window !== "undefined"
499
+ ? window.AudioContext || (window as any).webkitAudioContext || null
500
+ : null;
501
+ if (!AudioCtor) return;
502
+ const ctx: AudioContext = new AudioCtor();
503
+ const source = ctx.createMediaStreamSource(stream);
504
+ const analyser = ctx.createAnalyser();
505
+ analyser.fftSize = 512;
506
+ source.connect(analyser);
507
+ audioContextRef.current = ctx;
508
+ analyserRef.current = analyser;
509
+
510
+ const buffer = new Uint8Array(analyser.frequencyBinCount);
511
+ const tick = () => {
512
+ if (!analyserRef.current) return;
513
+ analyserRef.current.getByteTimeDomainData(buffer);
514
+ let sumSquares = 0;
515
+ for (let i = 0; i < buffer.length; i++) {
516
+ const n = (buffer[i] - 128) / 128;
517
+ sumSquares += n * n;
518
+ }
519
+ const rms = Math.sqrt(sumSquares / buffer.length);
520
+ setAmplitude(Math.min(1, rms * 2.5));
521
+ rafRef.current = requestAnimationFrame(tick);
522
+ };
523
+ rafRef.current = requestAnimationFrame(tick);
524
+ } catch {
525
+ /* analyser is best-effort */
526
+ }
527
+ }, []);
528
+
529
+ const startTimer = useCallback(() => {
530
+ startedAtRef.current = Date.now();
531
+ setDurationMs(0);
532
+ timerRef.current = setInterval(() => {
533
+ setDurationMs(Date.now() - startedAtRef.current);
534
+ }, 100);
535
+ }, []);
536
+
537
+ const startOpenAi = useCallback(
538
+ async (providerPref: VoiceProvider, instructions?: string) => {
539
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
540
+ // User may have pressed Escape (cancel) while the permission prompt was
541
+ // open. If so, stop the stream and bail before we start recording.
542
+ if (cancelledRef.current) {
543
+ for (const track of stream.getTracks()) track.stop();
544
+ cancelledRef.current = false;
545
+ setState("idle");
546
+ return;
547
+ }
548
+ mediaStreamRef.current = stream;
549
+ const mimeType = pickMimeType();
550
+ const recorder = new MediaRecorder(stream, { mimeType });
551
+ mediaRecorderRef.current = recorder;
552
+ chunksRef.current = [];
553
+
554
+ recorder.ondataavailable = (e) => {
555
+ if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
556
+ };
557
+ recorder.onstop = async () => {
558
+ const localChunks = chunksRef.current.slice();
559
+ const localMime = recorder.mimeType || mimeType;
560
+ const liveSnapshot = liveTextRef.current;
561
+ teardown();
562
+ if (cancelledRef.current) {
563
+ cancelledRef.current = false;
564
+ setState("idle");
565
+ return;
566
+ }
567
+ if (localChunks.length === 0) {
568
+ if (liveSnapshot) onTranscriptRef.current?.(liveSnapshot.trim());
569
+ setState("idle");
570
+ return;
571
+ }
572
+ setState("transcribing");
573
+ try {
574
+ const audioBlob = new Blob(localChunks, { type: localMime });
575
+ const form = new FormData();
576
+ form.append(
577
+ "audio",
578
+ audioBlob,
579
+ `voice.${localMime.split("/")[1] ?? "webm"}`,
580
+ );
581
+ form.append("provider", providerPref);
582
+ if (instructions?.trim()) {
583
+ form.append("instructions", instructions.trim());
584
+ }
585
+ appendVoiceContext(form, await getVoiceContextPack());
586
+ const res = await fetch(resolvePath(TRANSCRIBE_URL), {
587
+ method: "POST",
588
+ body: form,
589
+ });
590
+ if (!res.ok) {
591
+ const body = await res
592
+ .json()
593
+ .catch(() => ({ error: `HTTP ${res.status}` }));
594
+ throw new Error(
595
+ body.error || `Transcription failed (${res.status})`,
596
+ );
597
+ }
598
+ if (cancelledRef.current) {
599
+ cancelledRef.current = false;
600
+ setState("idle");
601
+ return;
602
+ }
603
+ const data = (await res.json()) as { text?: string };
604
+ const text = (data.text ?? "").trim();
605
+ if (text) {
606
+ onTranscriptRef.current?.(text);
607
+ } else if (liveSnapshot) {
608
+ onTranscriptRef.current?.(liveSnapshot.trim());
609
+ }
610
+ setState("idle");
611
+ } catch (err) {
612
+ if (cancelledRef.current) {
613
+ cancelledRef.current = false;
614
+ setState("idle");
615
+ return;
616
+ }
617
+ if (liveSnapshot) {
618
+ onTranscriptRef.current?.(liveSnapshot.trim());
619
+ setState("idle");
620
+ } else {
621
+ failWith(
622
+ (err as Error)?.message ??
623
+ "Transcription failed. Check your voice transcription provider in settings.",
624
+ );
625
+ }
626
+ }
627
+ };
628
+
629
+ startMeter(stream);
630
+ startTimer();
631
+ setState("recording");
632
+ recorder.start();
633
+
634
+ // Start parallel Web Speech recognition for live preview text.
635
+ // This runs alongside MediaRecorder so the user sees words appear
636
+ // immediately while the server provider processes the full recording later.
637
+ const SpeechCtor = getSpeechRecognitionCtor();
638
+ if (SpeechCtor) {
639
+ const liveSpeech = new SpeechCtor();
640
+ liveSpeech.continuous = true;
641
+ liveSpeech.interimResults = true;
642
+ liveSpeech.lang =
643
+ (typeof navigator !== "undefined" && navigator.language) || "en-US";
644
+ liveSpeechRef.current = liveSpeech;
645
+ liveTextRef.current = "";
646
+
647
+ liveSpeech.onresult = (event: any) => {
648
+ let interim = "";
649
+ for (let i = event.resultIndex; i < event.results.length; i++) {
650
+ const result = event.results[i];
651
+ const text = result[0]?.transcript ?? "";
652
+ if (result.isFinal) {
653
+ liveTextRef.current += text;
654
+ } else {
655
+ interim += text;
656
+ }
657
+ }
658
+ onLiveUpdateRef.current?.(liveTextRef.current, interim);
659
+ };
660
+
661
+ liveSpeech.onend = () => {
662
+ if (liveSpeechRef.current === liveSpeech) {
663
+ try {
664
+ liveSpeech.start();
665
+ } catch {
666
+ /* ignore */
667
+ }
668
+ }
669
+ };
670
+
671
+ liveSpeech.onerror = () => {};
672
+
673
+ try {
674
+ liveSpeech.start();
675
+ } catch {
676
+ /* best effort — live preview just won't appear */
677
+ }
678
+ }
679
+ },
680
+ [
681
+ startMeter,
682
+ startTimer,
683
+ teardown,
684
+ failWith,
685
+ getVoiceContextPack,
686
+ resolvePath,
687
+ ],
688
+ );
689
+
690
+ const startBrowser = useCallback(
691
+ async (prefs: VoicePrefs) => {
692
+ const Ctor = getSpeechRecognitionCtor();
693
+ if (!Ctor) {
694
+ throw new Error(
695
+ "Your browser doesn't support speech recognition. Add an OpenAI API key in settings for Whisper transcription.",
696
+ );
697
+ }
698
+ // Still request mic to drive the amplitude meter, so the UI doesn't look
699
+ // dead while the user talks. SpeechRecognition manages its own capture
700
+ // under the hood in most browsers.
701
+ let stream: MediaStream | null = null;
702
+ try {
703
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
704
+ mediaStreamRef.current = stream;
705
+ startMeter(stream);
706
+ } catch {
707
+ /* non-fatal — recognition can still work without our analyser */
708
+ }
709
+
710
+ if (cancelledRef.current) {
711
+ if (stream) for (const track of stream.getTracks()) track.stop();
712
+ mediaStreamRef.current = null;
713
+ cancelledRef.current = false;
714
+ setState("idle");
715
+ return;
716
+ }
717
+
718
+ const recognition = new Ctor();
719
+ recognition.continuous = true;
720
+ recognition.interimResults = true;
721
+ recognition.lang =
722
+ (typeof navigator !== "undefined" && navigator.language) || "en-US";
723
+ speechRef.current = recognition;
724
+ speechTranscriptRef.current = "";
725
+
726
+ recognition.onresult = (event: any) => {
727
+ let interim = "";
728
+ for (let i = event.resultIndex; i < event.results.length; i++) {
729
+ const result = event.results[i];
730
+ const text = result[0]?.transcript ?? "";
731
+ if (result.isFinal) {
732
+ speechTranscriptRef.current += text;
733
+ } else {
734
+ interim += text;
735
+ }
736
+ }
737
+ onLiveUpdateRef.current?.(speechTranscriptRef.current, interim);
738
+ };
739
+ recognition.onerror = (event: any) => {
740
+ if (event?.error === "no-speech" || event?.error === "aborted") return;
741
+ failWith(voiceDictationSpeechErrorMessage(event?.error));
742
+ };
743
+ recognition.onend = () => {
744
+ const text = speechTranscriptRef.current.trim();
745
+ const wasCancelled = cancelledRef.current;
746
+ cancelledRef.current = false;
747
+ teardown();
748
+ if (wasCancelled || !text) {
749
+ setState("idle");
750
+ return;
751
+ }
752
+ void (async () => {
753
+ let finalText = text;
754
+ if (prefs.cleanupEnabled) {
755
+ setState("transcribing");
756
+ try {
757
+ finalText = await cleanupRecognizedText({
758
+ text,
759
+ provider: prefs.provider,
760
+ instructions: prefs.instructions,
761
+ contextPack: await getVoiceContextPack(),
762
+ resolvePath,
763
+ replaceContext,
764
+ });
765
+ } catch {
766
+ finalText = text;
767
+ }
768
+ }
769
+ if (!cancelledRef.current && finalText) {
770
+ onTranscriptRef.current?.(finalText);
771
+ }
772
+ cancelledRef.current = false;
773
+ setState("idle");
774
+ })();
775
+ };
776
+
777
+ startTimer();
778
+ setState("recording");
779
+ recognition.start();
780
+ },
781
+ [
782
+ startMeter,
783
+ startTimer,
784
+ teardown,
785
+ failWith,
786
+ getVoiceContextPack,
787
+ replaceContext,
788
+ resolvePath,
789
+ ],
790
+ );
791
+
792
+ const startGoogleRealtime = useCallback(
793
+ async (prefs: VoicePrefs) => {
794
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
795
+ if (cancelledRef.current) {
796
+ for (const track of stream.getTracks()) track.stop();
797
+ cancelledRef.current = false;
798
+ setState("idle");
799
+ return;
800
+ }
801
+
802
+ const sessionRes = await fetch(resolvePath(GOOGLE_REALTIME_SESSION_URL), {
803
+ method: "POST",
804
+ headers: { "Content-Type": "application/json" },
805
+ body: JSON.stringify({
806
+ language:
807
+ (typeof navigator !== "undefined" && navigator.language) || "en-US",
808
+ }),
809
+ });
810
+ const sessionBody = (await sessionRes
811
+ .json()
812
+ .catch(() => ({ error: `HTTP ${sessionRes.status}` }))) as {
813
+ websocketUrl?: string;
814
+ websocketProtocol?: string;
815
+ sessionToken?: string;
816
+ error?: string;
817
+ };
818
+ if (
819
+ !sessionRes.ok ||
820
+ !sessionBody.websocketUrl ||
821
+ !sessionBody.sessionToken
822
+ ) {
823
+ for (const track of stream.getTracks()) track.stop();
824
+ throw new Error(
825
+ sessionBody.error ||
826
+ `Could not start Google realtime transcription (${sessionRes.status})`,
827
+ );
828
+ }
829
+ if (cancelledRef.current) {
830
+ for (const track of stream.getTracks()) track.stop();
831
+ cancelledRef.current = false;
832
+ setState("idle");
833
+ return;
834
+ }
835
+
836
+ mediaStreamRef.current = stream;
837
+ const mimeType = pickMimeType();
838
+ const recorder = new MediaRecorder(stream, { mimeType });
839
+ mediaRecorderRef.current = recorder;
840
+ realtimeFinalRef.current = "";
841
+ realtimeInterimRef.current = "";
842
+
843
+ let finalized = false;
844
+ let recorderStarted = false;
845
+ const finish = (errorMessage?: string) => {
846
+ if (finalized) return;
847
+ finalized = true;
848
+ void (async () => {
849
+ const finalText = realtimeFinalRef.current.trim();
850
+ const interimText = realtimeInterimRef.current.trim();
851
+ const text = [finalText, interimText]
852
+ .filter(Boolean)
853
+ .join(" ")
854
+ .trim();
855
+ teardown();
856
+ if (cancelledRef.current) {
857
+ cancelledRef.current = false;
858
+ setState("idle");
859
+ return;
860
+ }
861
+ if (text) {
862
+ let cleanedText = text;
863
+ if (prefs.cleanupEnabled) {
864
+ setState("transcribing");
865
+ try {
866
+ cleanedText = await cleanupRecognizedText({
867
+ text,
868
+ provider: prefs.provider,
869
+ instructions: prefs.instructions,
870
+ contextPack: await getVoiceContextPack(),
871
+ resolvePath,
872
+ replaceContext,
873
+ });
874
+ } catch {
875
+ cleanedText = text;
876
+ }
877
+ }
878
+ if (!cancelledRef.current && cleanedText) {
879
+ onTranscriptRef.current?.(cleanedText);
880
+ }
881
+ cancelledRef.current = false;
882
+ setState("idle");
883
+ return;
884
+ }
885
+ if (errorMessage) {
886
+ failWith(errorMessage);
887
+ return;
888
+ }
889
+ setState("idle");
890
+ })();
891
+ };
892
+
893
+ recorder.ondataavailable = async (event) => {
894
+ if (!event.data || event.data.size === 0) return;
895
+ const socket = realtimeSocketRef.current;
896
+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
897
+ try {
898
+ socket.send(await event.data.arrayBuffer());
899
+ } catch (err) {
900
+ finish((err as Error)?.message ?? "Realtime audio upload failed");
901
+ }
902
+ };
903
+
904
+ recorder.onstop = () => {
905
+ const socket = realtimeSocketRef.current;
906
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
907
+ finish();
908
+ return;
909
+ }
910
+ setState("transcribing");
911
+ try {
912
+ socket.send(JSON.stringify({ type: "stop" }));
913
+ } catch {
914
+ finish();
915
+ return;
916
+ }
917
+ realtimeStopTimeoutRef.current = window.setTimeout(() => {
918
+ const activeSocket = realtimeSocketRef.current;
919
+ if (activeSocket?.readyState === WebSocket.OPEN) {
920
+ try {
921
+ activeSocket.close();
922
+ } catch {
923
+ finish();
924
+ }
925
+ return;
926
+ }
927
+ finish();
928
+ }, 10000);
929
+ };
930
+
931
+ const socket = new WebSocket(sessionBody.websocketUrl, [
932
+ sessionBody.websocketProtocol || GOOGLE_REALTIME_WS_PROTOCOL,
933
+ sessionBody.sessionToken,
934
+ ]);
935
+ socket.binaryType = "arraybuffer";
936
+ realtimeSocketRef.current = socket;
937
+
938
+ socket.onmessage = (event) => {
939
+ if (cancelledRef.current) {
940
+ finish();
941
+ return;
942
+ }
943
+ const raw =
944
+ typeof event.data === "string"
945
+ ? event.data
946
+ : event.data instanceof Blob
947
+ ? null
948
+ : new TextDecoder().decode(event.data);
949
+ if (!raw) return;
950
+ let message:
951
+ | {
952
+ type?: string;
953
+ text?: string;
954
+ error?: string;
955
+ }
956
+ | undefined;
957
+ try {
958
+ message = JSON.parse(raw);
959
+ } catch {
960
+ return;
961
+ }
962
+ if (!message?.type) return;
963
+ if (message.type === "ready") {
964
+ if (!recorderStarted) {
965
+ recorderStarted = true;
966
+ startMeter(stream);
967
+ startTimer();
968
+ setState("recording");
969
+ recorder.start(100);
970
+ }
971
+ return;
972
+ }
973
+ if (message.type === "partial") {
974
+ realtimeInterimRef.current = (message.text ?? "").trim();
975
+ onLiveUpdateRef.current?.(
976
+ realtimeFinalRef.current,
977
+ realtimeInterimRef.current,
978
+ );
979
+ return;
980
+ }
981
+ if (message.type === "final") {
982
+ const next = (message.text ?? "").trim();
983
+ if (next) {
984
+ realtimeFinalRef.current = [realtimeFinalRef.current.trim(), next]
985
+ .filter(Boolean)
986
+ .join(" ");
987
+ }
988
+ realtimeInterimRef.current = "";
989
+ onLiveUpdateRef.current?.(realtimeFinalRef.current, "");
990
+ return;
991
+ }
992
+ if (message.type === "error") {
993
+ finish(message.error || "Google realtime transcription failed");
994
+ return;
995
+ }
996
+ if (message.type === "end") {
997
+ finish();
998
+ }
999
+ };
1000
+
1001
+ socket.onerror = () => {
1002
+ finish("Google realtime transcription connection failed");
1003
+ };
1004
+
1005
+ socket.onclose = () => {
1006
+ finish();
1007
+ };
1008
+
1009
+ socket.onopen = () => {
1010
+ if (cancelledRef.current) {
1011
+ finish();
1012
+ return;
1013
+ }
1014
+ try {
1015
+ socket.send(
1016
+ JSON.stringify({
1017
+ type: "start",
1018
+ language:
1019
+ (typeof navigator !== "undefined" && navigator.language) ||
1020
+ "en-US",
1021
+ interimResults: true,
1022
+ mimeType,
1023
+ }),
1024
+ );
1025
+ } catch (err) {
1026
+ finish((err as Error)?.message ?? "Could not start realtime stream");
1027
+ }
1028
+ };
1029
+ },
1030
+ [
1031
+ startMeter,
1032
+ startTimer,
1033
+ teardown,
1034
+ failWith,
1035
+ getVoiceContextPack,
1036
+ replaceContext,
1037
+ resolvePath,
1038
+ ],
1039
+ );
1040
+
1041
+ const start = useCallback(async () => {
1042
+ if (state === "recording" || state === "starting") return;
1043
+ setErrorMessage(null);
1044
+ setState("starting");
1045
+ cancelledRef.current = false;
1046
+
1047
+ const prefs = await readVoicePrefs(resolvePath);
1048
+ const pref = prefs.provider;
1049
+ setProvider(pref);
1050
+
1051
+ // In "auto" mode, prefer browser-native SpeechRecognition when available.
1052
+ // It requires no server-side API key, streams words incrementally into the
1053
+ // composer, and matches the macros-app record-button experience. Fall back
1054
+ // to the server upload path only when SpeechRecognition isn't supported.
1055
+ // Explicit server providers (builder, gemini, groq, openai) always use the
1056
+ // MediaRecorder → server upload path regardless.
1057
+ const resolvedProvider: VoiceProvider =
1058
+ pref === "auto" && speechSupported
1059
+ ? "browser"
1060
+ : pref === "auto" ||
1061
+ pref === "builder" ||
1062
+ pref === "builder-gemini" ||
1063
+ pref === "gemini" ||
1064
+ pref === "groq"
1065
+ ? "openai"
1066
+ : pref;
1067
+ activeProviderRef.current = resolvedProvider;
1068
+
1069
+ try {
1070
+ if (resolvedProvider === "openai") {
1071
+ if (!mediaRecorderSupported) {
1072
+ throw new Error(
1073
+ "Your browser doesn't support audio recording. Use the browser provider in Settings → Voice Transcription.",
1074
+ );
1075
+ }
1076
+ await startOpenAi(pref, prefs.instructions);
1077
+ } else if (resolvedProvider === "google-realtime") {
1078
+ if (!mediaRecorderSupported) {
1079
+ throw new Error(
1080
+ "Your browser doesn't support audio recording, so Google realtime transcription can't start.",
1081
+ );
1082
+ }
1083
+ await startGoogleRealtime(prefs);
1084
+ } else {
1085
+ await startBrowser(prefs);
1086
+ }
1087
+ } catch (err) {
1088
+ if (cancelledRef.current) {
1089
+ cancelledRef.current = false;
1090
+ teardown();
1091
+ setState("idle");
1092
+ return;
1093
+ }
1094
+ failWith(voiceDictationStartErrorMessage(err));
1095
+ }
1096
+ }, [
1097
+ state,
1098
+ speechSupported,
1099
+ mediaRecorderSupported,
1100
+ startOpenAi,
1101
+ startGoogleRealtime,
1102
+ startBrowser,
1103
+ failWith,
1104
+ ]);
1105
+
1106
+ const stop = useCallback(() => {
1107
+ if (state !== "recording") return;
1108
+ cancelledRef.current = false;
1109
+ if (
1110
+ (activeProviderRef.current === "openai" ||
1111
+ activeProviderRef.current === "google-realtime") &&
1112
+ mediaRecorderRef.current
1113
+ ) {
1114
+ try {
1115
+ mediaRecorderRef.current.stop();
1116
+ } catch {
1117
+ teardown();
1118
+ setState("idle");
1119
+ }
1120
+ } else if (speechRef.current) {
1121
+ try {
1122
+ speechRef.current.stop();
1123
+ } catch {
1124
+ teardown();
1125
+ setState("idle");
1126
+ }
1127
+ } else {
1128
+ teardown();
1129
+ setState("idle");
1130
+ }
1131
+ }, [state, teardown]);
1132
+
1133
+ const cancel = useCallback(() => {
1134
+ if (
1135
+ state !== "recording" &&
1136
+ state !== "starting" &&
1137
+ state !== "transcribing"
1138
+ )
1139
+ return;
1140
+ cancelledRef.current = true;
1141
+ if (
1142
+ (activeProviderRef.current === "openai" ||
1143
+ activeProviderRef.current === "google-realtime") &&
1144
+ mediaRecorderRef.current
1145
+ ) {
1146
+ try {
1147
+ mediaRecorderRef.current.stop();
1148
+ } catch {
1149
+ /* ignore */
1150
+ }
1151
+ } else if (speechRef.current) {
1152
+ try {
1153
+ speechRef.current.abort?.();
1154
+ } catch {
1155
+ /* ignore */
1156
+ }
1157
+ }
1158
+ teardown();
1159
+ setState("idle");
1160
+ }, [state, teardown]);
1161
+
1162
+ // Auto-dismiss error after 8s so a stale "permission denied" message doesn't
1163
+ // sit forever after the user fixes the underlying permission. Manual dismiss
1164
+ // (via dismissError) and click-to-retry both also clear the error sooner.
1165
+ useEffect(() => {
1166
+ if (state !== "error") return;
1167
+ const handle = setTimeout(() => {
1168
+ setErrorMessage(null);
1169
+ setState("idle");
1170
+ }, 8000);
1171
+ return () => clearTimeout(handle);
1172
+ }, [state]);
1173
+
1174
+ const dismissError = useCallback(() => {
1175
+ setErrorMessage(null);
1176
+ if (state === "error") setState("idle");
1177
+ }, [state]);
1178
+
1179
+ return {
1180
+ state,
1181
+ amplitude,
1182
+ durationMs,
1183
+ errorMessage,
1184
+ provider,
1185
+ supported,
1186
+ start,
1187
+ stop,
1188
+ cancel,
1189
+ dismissError,
1190
+ };
1191
+ }