devglide 0.1.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.
Files changed (252) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -0
  3. package/bin/claude-md-template.js +94 -0
  4. package/bin/devglide.js +387 -0
  5. package/package.json +85 -0
  6. package/pnpm-workspace.yaml +3 -0
  7. package/src/apps/coder/.turbo/turbo-lint.log +5 -0
  8. package/src/apps/coder/package.json +16 -0
  9. package/src/apps/coder/public/favicon.svg +7 -0
  10. package/src/apps/coder/public/page.css +275 -0
  11. package/src/apps/coder/public/page.js +528 -0
  12. package/src/apps/coder/server.js +3 -0
  13. package/src/apps/documentation/public/page.css +597 -0
  14. package/src/apps/documentation/public/page.js +609 -0
  15. package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
  16. package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
  17. package/src/apps/kanban/package.json +32 -0
  18. package/src/apps/kanban/public/favicon.svg +7 -0
  19. package/src/apps/kanban/public/page.css +1010 -0
  20. package/src/apps/kanban/public/page.js +1730 -0
  21. package/src/apps/kanban/public/vendor/marked.min.js +6 -0
  22. package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
  23. package/src/apps/kanban/src/db.ts +319 -0
  24. package/src/apps/kanban/src/index.ts +14 -0
  25. package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
  26. package/src/apps/kanban/src/mcp-helpers.ts +60 -0
  27. package/src/apps/kanban/src/mcp.ts +59 -0
  28. package/src/apps/kanban/src/routes/attachments.ts +161 -0
  29. package/src/apps/kanban/src/routes/features.ts +233 -0
  30. package/src/apps/kanban/src/routes/issues.ts +373 -0
  31. package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
  32. package/src/apps/kanban/src/tools/item-tools.ts +307 -0
  33. package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
  34. package/src/apps/kanban/tsconfig.check.json +9 -0
  35. package/src/apps/kanban/tsconfig.json +9 -0
  36. package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
  37. package/src/apps/keymap/package.json +16 -0
  38. package/src/apps/keymap/public/page.css +275 -0
  39. package/src/apps/keymap/public/page.js +294 -0
  40. package/src/apps/keymap/server.js +25 -0
  41. package/src/apps/log/.turbo/turbo-build.log +5 -0
  42. package/src/apps/log/.turbo/turbo-lint.log +45 -0
  43. package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
  44. package/src/apps/log/node_modules/.bin/tsc +21 -0
  45. package/src/apps/log/node_modules/.bin/tsserver +21 -0
  46. package/src/apps/log/node_modules/.bin/tsx +21 -0
  47. package/src/apps/log/package.json +36 -0
  48. package/src/apps/log/public/console-sniffer.js +221 -0
  49. package/src/apps/log/public/favicon.svg +7 -0
  50. package/src/apps/log/public/page.css +322 -0
  51. package/src/apps/log/public/page.js +463 -0
  52. package/src/apps/log/src/index.ts +9 -0
  53. package/src/apps/log/src/mcp.ts +122 -0
  54. package/src/apps/log/src/routes/log.ts +333 -0
  55. package/src/apps/log/src/routes/status.ts +25 -0
  56. package/src/apps/log/src/server-sniffer.ts +118 -0
  57. package/src/apps/log/src/services/file-patterns.ts +39 -0
  58. package/src/apps/log/src/services/file-tailer.ts +228 -0
  59. package/src/apps/log/src/services/line-parser.ts +94 -0
  60. package/src/apps/log/src/services/log-writer.ts +39 -0
  61. package/src/apps/log/tsconfig.json +8 -0
  62. package/src/apps/prompts/.turbo/turbo-build.log +5 -0
  63. package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
  64. package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
  65. package/src/apps/prompts/mcp.ts +175 -0
  66. package/src/apps/prompts/node_modules/.bin/tsc +21 -0
  67. package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
  68. package/src/apps/prompts/node_modules/.bin/tsx +21 -0
  69. package/src/apps/prompts/package.json +25 -0
  70. package/src/apps/prompts/public/page.css +315 -0
  71. package/src/apps/prompts/public/page.js +541 -0
  72. package/src/apps/prompts/services/prompt-store.ts +212 -0
  73. package/src/apps/prompts/src/index.ts +9 -0
  74. package/src/apps/prompts/tsconfig.json +8 -0
  75. package/src/apps/prompts/types.ts +27 -0
  76. package/src/apps/shell/.turbo/turbo-build.log +5 -0
  77. package/src/apps/shell/.turbo/turbo-lint.log +34 -0
  78. package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
  79. package/src/apps/shell/package.json +35 -0
  80. package/src/apps/shell/public/favicon.svg +7 -0
  81. package/src/apps/shell/public/page.css +407 -0
  82. package/src/apps/shell/public/page.js +1577 -0
  83. package/src/apps/shell/src/index.ts +150 -0
  84. package/src/apps/shell/src/mcp.ts +398 -0
  85. package/src/apps/shell/src/shell-types.ts +41 -0
  86. package/src/apps/shell/tsconfig.json +8 -0
  87. package/src/apps/test/.turbo/turbo-build.log +5 -0
  88. package/src/apps/test/.turbo/turbo-lint.log +27 -0
  89. package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
  90. package/src/apps/test/node_modules/.bin/tsc +21 -0
  91. package/src/apps/test/node_modules/.bin/tsserver +21 -0
  92. package/src/apps/test/node_modules/.bin/tsx +21 -0
  93. package/src/apps/test/node_modules/.bin/uuid +21 -0
  94. package/src/apps/test/package.json +35 -0
  95. package/src/apps/test/public/favicon.svg +7 -0
  96. package/src/apps/test/public/page.css +499 -0
  97. package/src/apps/test/public/page.js +417 -0
  98. package/src/apps/test/public/scenario-runner.js +450 -0
  99. package/src/apps/test/src/index.ts +9 -0
  100. package/src/apps/test/src/mcp.ts +192 -0
  101. package/src/apps/test/src/routes/trigger.ts +285 -0
  102. package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
  103. package/src/apps/test/src/services/scenario-manager.ts +361 -0
  104. package/src/apps/test/src/services/scenario-store.ts +145 -0
  105. package/src/apps/test/tsconfig.json +8 -0
  106. package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
  107. package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
  108. package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
  109. package/src/apps/vocabulary/mcp.ts +173 -0
  110. package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
  111. package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
  112. package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
  113. package/src/apps/vocabulary/package.json +25 -0
  114. package/src/apps/vocabulary/public/page.css +247 -0
  115. package/src/apps/vocabulary/public/page.js +444 -0
  116. package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
  117. package/src/apps/vocabulary/src/index.ts +10 -0
  118. package/src/apps/vocabulary/tsconfig.json +8 -0
  119. package/src/apps/vocabulary/types.ts +22 -0
  120. package/src/apps/voice/.turbo/turbo-build.log +5 -0
  121. package/src/apps/voice/.turbo/turbo-lint.log +43 -0
  122. package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
  123. package/src/apps/voice/node_modules/.bin/openai +21 -0
  124. package/src/apps/voice/node_modules/.bin/tsc +21 -0
  125. package/src/apps/voice/node_modules/.bin/tsserver +21 -0
  126. package/src/apps/voice/node_modules/.bin/tsx +21 -0
  127. package/src/apps/voice/package.json +35 -0
  128. package/src/apps/voice/public/favicon.svg +7 -0
  129. package/src/apps/voice/public/page.css +388 -0
  130. package/src/apps/voice/public/page.js +718 -0
  131. package/src/apps/voice/src/index.ts +10 -0
  132. package/src/apps/voice/src/mcp.ts +70 -0
  133. package/src/apps/voice/src/providers/index.ts +85 -0
  134. package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
  135. package/src/apps/voice/src/providers/types.ts +27 -0
  136. package/src/apps/voice/src/routes/config.ts +118 -0
  137. package/src/apps/voice/src/routes/transcribe.ts +90 -0
  138. package/src/apps/voice/src/services/config-store.ts +129 -0
  139. package/src/apps/voice/src/services/stats.ts +108 -0
  140. package/src/apps/voice/src/transcribe.ts +11 -0
  141. package/src/apps/voice/src/utils/mime.ts +16 -0
  142. package/src/apps/voice/tsconfig.json +8 -0
  143. package/src/apps/workflow/.turbo/turbo-build.log +5 -0
  144. package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
  145. package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
  146. package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
  147. package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
  148. package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
  149. package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
  150. package/src/apps/workflow/engine/executors/index.ts +28 -0
  151. package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
  152. package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
  153. package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
  154. package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
  155. package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
  156. package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
  157. package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
  158. package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
  159. package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
  160. package/src/apps/workflow/engine/graph-runner.ts +438 -0
  161. package/src/apps/workflow/engine/node-executor.ts +104 -0
  162. package/src/apps/workflow/engine/node-registry.ts +15 -0
  163. package/src/apps/workflow/engine/variable-resolver.ts +109 -0
  164. package/src/apps/workflow/mcp.ts +223 -0
  165. package/src/apps/workflow/node_modules/.bin/tsc +21 -0
  166. package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
  167. package/src/apps/workflow/node_modules/.bin/tsx +21 -0
  168. package/src/apps/workflow/package.json +25 -0
  169. package/src/apps/workflow/public/editor/canvas.js +366 -0
  170. package/src/apps/workflow/public/editor/drag-manager.js +326 -0
  171. package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
  172. package/src/apps/workflow/public/editor/history-manager.js +147 -0
  173. package/src/apps/workflow/public/editor/layout-engine.js +159 -0
  174. package/src/apps/workflow/public/editor/node-renderer.js +199 -0
  175. package/src/apps/workflow/public/editor/selection-manager.js +193 -0
  176. package/src/apps/workflow/public/favicon.svg +7 -0
  177. package/src/apps/workflow/public/models/node-types.js +300 -0
  178. package/src/apps/workflow/public/models/workflow-model.js +257 -0
  179. package/src/apps/workflow/public/page.css +406 -0
  180. package/src/apps/workflow/public/page.js +658 -0
  181. package/src/apps/workflow/public/panels/inspector.js +360 -0
  182. package/src/apps/workflow/public/panels/palette.js +106 -0
  183. package/src/apps/workflow/public/panels/run-view.js +275 -0
  184. package/src/apps/workflow/public/panels/toolbar.js +232 -0
  185. package/src/apps/workflow/public/panels/workflow-list.js +237 -0
  186. package/src/apps/workflow/public/state/store.js +47 -0
  187. package/src/apps/workflow/services/custom-node-loader.ts +48 -0
  188. package/src/apps/workflow/services/legacy-converter.ts +72 -0
  189. package/src/apps/workflow/services/run-manager.ts +190 -0
  190. package/src/apps/workflow/services/workflow-store.ts +424 -0
  191. package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
  192. package/src/apps/workflow/services/workflow-validator.ts +98 -0
  193. package/src/apps/workflow/src/index.ts +10 -0
  194. package/src/apps/workflow/templates/ci-pipeline.json +18 -0
  195. package/src/apps/workflow/templates/code-review.json +22 -0
  196. package/src/apps/workflow/templates/kanban-testing.json +24 -0
  197. package/src/apps/workflow/tsconfig.json +8 -0
  198. package/src/apps/workflow/types.ts +268 -0
  199. package/src/packages/auth-middleware.ts +14 -0
  200. package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
  201. package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
  202. package/src/packages/design-tokens/build.js +413 -0
  203. package/src/packages/design-tokens/demo/index.html +1367 -0
  204. package/src/packages/design-tokens/demo/proposition-a.html +717 -0
  205. package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
  206. package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
  207. package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
  208. package/src/packages/design-tokens/dist/tokens.css +345 -0
  209. package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
  210. package/src/packages/design-tokens/dist/tokens.js +386 -0
  211. package/src/packages/design-tokens/package.json +25 -0
  212. package/src/packages/design-tokens/tokens.json +228 -0
  213. package/src/packages/devtools-middleware.ts +22 -0
  214. package/src/packages/eslint-config/index.js +63 -0
  215. package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
  216. package/src/packages/eslint-config/package.json +18 -0
  217. package/src/packages/json-file-store.ts +232 -0
  218. package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
  219. package/src/packages/mcp-utils/dist/index.d.ts +33 -0
  220. package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
  221. package/src/packages/mcp-utils/dist/index.js +126 -0
  222. package/src/packages/mcp-utils/dist/index.js.map +1 -0
  223. package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
  224. package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
  225. package/src/packages/mcp-utils/package.json +32 -0
  226. package/src/packages/mcp-utils/src/index.ts +171 -0
  227. package/src/packages/mcp-utils/tsconfig.json +9 -0
  228. package/src/packages/paths.ts +18 -0
  229. package/src/packages/project-context/index.js +55 -0
  230. package/src/packages/project-context/package.json +13 -0
  231. package/src/packages/project-store.ts +127 -0
  232. package/src/packages/server-sniffer.ts +132 -0
  233. package/src/packages/shared-assets/favicon.svg +7 -0
  234. package/src/packages/shared-assets/keymap-registry.js +512 -0
  235. package/src/packages/shared-assets/logo.svg +6 -0
  236. package/src/packages/shared-assets/package.json +11 -0
  237. package/src/packages/shared-assets/ui-utils.js +48 -0
  238. package/src/packages/shared-assets/voice-widget.d.ts +37 -0
  239. package/src/packages/shared-assets/voice-widget.js +695 -0
  240. package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
  241. package/src/packages/shared-types/dist/index.d.ts +39 -0
  242. package/src/packages/shared-types/dist/index.d.ts.map +1 -0
  243. package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
  244. package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
  245. package/src/packages/shared-types/package.json +25 -0
  246. package/src/packages/shared-types/src/index.ts +41 -0
  247. package/src/packages/shared-types/tsconfig.json +11 -0
  248. package/src/packages/tsconfig/base.json +15 -0
  249. package/src/packages/tsconfig/next.json +14 -0
  250. package/src/packages/tsconfig/node.json +11 -0
  251. package/src/packages/tsconfig/package.json +10 -0
  252. package/turbo.json +25 -0
@@ -0,0 +1,718 @@
1
+ // ── Voice App — Page Module ──────────────────────────────────────────
2
+ // ES module: mount(container, ctx), unmount(container), onProjectChange(project)
3
+
4
+ let _container = null;
5
+ let _statsTimer = null;
6
+ let _visibilityHandler = null;
7
+ let _escapeHandler = null;
8
+
9
+ let allProviders = [];
10
+ let currentProviderId = 'openai';
11
+ let audioFile = null;
12
+
13
+ // ── HTML ─────────────────────────────────────────────────────────────
14
+
15
+ const BODY_HTML = `
16
+ <div class="voice-toast-container" id="voice-toast-container"></div>
17
+
18
+ <!-- No Microphone Modal -->
19
+ <div id="mic-modal" class="modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="mic-modal-title">
20
+ <div class="modal">
21
+ <div class="modal-icon">
22
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
23
+ <line x1="1" y1="1" x2="23" y2="23"/>
24
+ <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
25
+ <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
26
+ <line x1="12" y1="19" x2="12" y2="23"/>
27
+ <line x1="8" y1="23" x2="16" y2="23"/>
28
+ </svg>
29
+ </div>
30
+ <h2 id="mic-modal-title">No Microphone Detected</h2>
31
+ <p class="modal-message" id="mic-modal-detail">No microphone device was found or access was denied.</p>
32
+ <p class="modal-hint">You can still transcribe audio using the file upload below.</p>
33
+ <button type="button" id="mic-modal-close" class="btn btn-secondary">Dismiss</button>
34
+ </div>
35
+ </div>
36
+
37
+ <header>
38
+ <span class="app-name">Voice</span>
39
+ <span id="status-badge" class="badge badge--unknown" role="status">checking...</span>
40
+ </header>
41
+
42
+ <div class="voice-container">
43
+ <!-- Configuration -->
44
+ <section class="card">
45
+ <div class="card-header">
46
+ <h2>Configuration</h2>
47
+ </div>
48
+ <form id="config-form" autocomplete="off">
49
+ <div class="form-row">
50
+ <div class="form-group">
51
+ <label for="voice-provider-select">Provider</label>
52
+ <select id="voice-provider-select"></select>
53
+ </div>
54
+ <div class="form-group">
55
+ <label for="voice-language-select">Language</label>
56
+ <select id="voice-language-select">
57
+ <option value="auto">auto-detect</option>
58
+ <option value="en">English</option>
59
+ <option value="de">German</option>
60
+ <option value="fr">French</option>
61
+ <option value="es">Spanish</option>
62
+ <option value="it">Italian</option>
63
+ <option value="pt">Portuguese</option>
64
+ <option value="nl">Dutch</option>
65
+ <option value="ja">Japanese</option>
66
+ <option value="ko">Korean</option>
67
+ <option value="zh">Chinese</option>
68
+ <option value="ru">Russian</option>
69
+ <option value="ar">Arabic</option>
70
+ <option value="hi">Hindi</option>
71
+ <option value="pl">Polish</option>
72
+ <option value="uk">Ukrainian</option>
73
+ </select>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="form-group">
78
+ <label for="voice-model-input">Model</label>
79
+ <input type="text" id="voice-model-input" placeholder="e.g. whisper-1" spellcheck="false">
80
+ </div>
81
+
82
+ <div id="api-key-group" class="form-group hidden">
83
+ <label for="voice-api-key-input">
84
+ API Key
85
+ <span id="api-key-current" class="field-hint"></span>
86
+ </label>
87
+ <div class="input-with-action">
88
+ <input type="password" id="voice-api-key-input"
89
+ placeholder="Paste new key, or leave blank to keep current"
90
+ autocomplete="new-password" spellcheck="false"
91
+ aria-describedby="api-key-current">
92
+ <button type="button" id="toggle-key-btn" class="btn-icon" title="Toggle visibility" aria-label="Toggle API key visibility">
93
+ <svg id="eye-icon" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
94
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
95
+ <circle cx="12" cy="12" r="3"/>
96
+ </svg>
97
+ <svg id="eye-off-icon" class="hidden" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
98
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
99
+ <line x1="1" y1="1" x2="23" y2="23"/>
100
+ </svg>
101
+ </button>
102
+ </div>
103
+ </div>
104
+
105
+ <div id="base-url-group" class="form-group hidden">
106
+ <label for="voice-base-url-input">Base URL</label>
107
+ <input type="url" id="voice-base-url-input" placeholder="http://localhost:8080" spellcheck="false">
108
+ </div>
109
+
110
+ <div class="form-actions">
111
+ <button type="button" id="test-btn" class="btn btn-secondary">
112
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
113
+ <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
114
+ </svg>
115
+ Test Connection
116
+ </button>
117
+ <span id="test-result" class="test-result"></span>
118
+ <button type="submit" id="save-btn" class="btn btn-primary">Save</button>
119
+ </div>
120
+ </form>
121
+ </section>
122
+
123
+ <!-- Statistics -->
124
+ <section class="card">
125
+ <div class="card-header">
126
+ <h2>Statistics</h2>
127
+ <button id="reset-stats-btn" class="btn btn-secondary">Reset</button>
128
+ </div>
129
+ <div class="stats-grid">
130
+ <div class="stat-card">
131
+ <span class="stat-value" id="total-transcriptions">0</span>
132
+ <span class="stat-label">Transcriptions</span>
133
+ </div>
134
+ <div class="stat-card">
135
+ <span class="stat-value" id="total-duration">0s</span>
136
+ <span class="stat-label">Audio Processed</span>
137
+ </div>
138
+ <div class="stat-card">
139
+ <span class="stat-value" id="total-errors">0</span>
140
+ <span class="stat-label">Errors</span>
141
+ </div>
142
+ <div class="stat-card">
143
+ <span class="stat-value" id="last-transcription">never</span>
144
+ <span class="stat-label">Last Transcription</span>
145
+ </div>
146
+ </div>
147
+ </section>
148
+
149
+ <!-- Test Transcription -->
150
+ <section class="card">
151
+ <h2>Test Transcription</h2>
152
+ <div class="upload-area" id="upload-area" aria-label="Audio file upload drop zone">
153
+ <input type="file" id="audio-file" accept="audio/*,.wav,.mp3,.m4a,.ogg,.flac,.webm" class="hidden">
154
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="upload-icon">
155
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
156
+ <polyline points="17 8 12 3 7 8"/>
157
+ <line x1="12" y1="3" x2="12" y2="15"/>
158
+ </svg>
159
+ <p class="upload-hint">
160
+ Drop an audio file here, or
161
+ <button type="button" id="choose-file-btn" class="btn-link">browse</button>
162
+ </p>
163
+ <p id="chosen-file-name" class="file-name hidden"></p>
164
+ </div>
165
+ <div class="transcribe-row">
166
+ <button id="transcribe-btn" class="btn btn-primary" disabled>Transcribe</button>
167
+ <span id="transcribe-status" class="field-hint"></span>
168
+ </div>
169
+ <div id="transcription-result" class="result-box hidden">
170
+ <div id="result-meta" class="result-meta"></div>
171
+ <p id="result-text"></p>
172
+ </div>
173
+ </section>
174
+
175
+ <!-- MCP Tools -->
176
+ <section class="card">
177
+ <h2>MCP Tools</h2>
178
+ <div class="tools-list">
179
+ <div class="tool">
180
+ <code>voice_transcribe</code>
181
+ <span id="tool-transcribe-desc">Transcribe base64 audio via configured provider</span>
182
+ </div>
183
+ <div class="tool">
184
+ <code>voice_status</code>
185
+ <span>Check service status and statistics</span>
186
+ </div>
187
+ </div>
188
+ </section>
189
+ </div>
190
+ `;
191
+
192
+ // ── Helpers ──────────────────────────────────────────────────────────
193
+
194
+ function $(sel) { return _container?.querySelector(sel); }
195
+
196
+ function arrayBufferToBase64(buffer) {
197
+ const bytes = new Uint8Array(buffer);
198
+ let binary = '';
199
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
200
+ return btoa(binary);
201
+ }
202
+
203
+ const AUDIO_EXTENSIONS = ['.wav', '.mp3', '.m4a', '.ogg', '.flac', '.webm', '.aac', '.wma', '.opus'];
204
+ function isAudioFile(file) {
205
+ if (file.type && file.type.startsWith('audio/')) return true;
206
+ return AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
207
+ }
208
+
209
+ function setLoading(btn, loading) {
210
+ btn.disabled = loading;
211
+ btn.dataset.originalText = btn.dataset.originalText ?? btn.textContent.trim();
212
+ btn.textContent = loading ? '...' : btn.dataset.originalText;
213
+ }
214
+
215
+ function showToast(message, type = 'info') {
216
+ const container = $('#voice-toast-container');
217
+ if (!container) return;
218
+ const toast = document.createElement('div');
219
+ toast.className = `toast toast--${type}`;
220
+ toast.textContent = message;
221
+ container.appendChild(toast);
222
+ toast.getBoundingClientRect();
223
+ toast.classList.add('toast--visible');
224
+ setTimeout(() => {
225
+ toast.classList.remove('toast--visible');
226
+ const fallback = setTimeout(() => toast.remove(), 500);
227
+ toast.addEventListener('transitionend', () => { clearTimeout(fallback); toast.remove(); }, { once: true });
228
+ }, 3200);
229
+ }
230
+
231
+ // ── Status badge ────────────────────────────────────────────────────
232
+
233
+ function updateStatusBadge(configured) {
234
+ const badge = $('#status-badge');
235
+ if (!badge) return;
236
+ if (configured === null) {
237
+ badge.textContent = 'offline';
238
+ badge.className = 'badge badge--error';
239
+ } else if (configured) {
240
+ badge.textContent = 'connected';
241
+ badge.className = 'badge badge--ok';
242
+ } else {
243
+ badge.textContent = 'not configured';
244
+ badge.className = 'badge badge--error';
245
+ }
246
+ }
247
+
248
+ // ── Provider form ───────────────────────────────────────────────────
249
+
250
+ function getSelectedProvider() {
251
+ return allProviders.find(p => p.id === currentProviderId) ?? null;
252
+ }
253
+
254
+ function populateProviderDropdown() {
255
+ const select = $('#voice-provider-select');
256
+ if (!select) return;
257
+ select.innerHTML = allProviders.map(p => `<option value="${p.id}">${p.displayName}</option>`).join('');
258
+ select.value = currentProviderId;
259
+ }
260
+
261
+ function updateFormForProvider(provider) {
262
+ const apiKeyGroup = $('#api-key-group');
263
+ const baseUrlGroup = $('#base-url-group');
264
+ const modelInput = $('#voice-model-input');
265
+ const apiKeyInput = $('#voice-api-key-input');
266
+ const baseUrlInput = $('#voice-base-url-input');
267
+ const apiKeyHint = $('#api-key-current');
268
+
269
+ if (!modelInput) return;
270
+
271
+ modelInput.value = provider.currentModel ?? provider.defaultModel;
272
+ modelInput.placeholder = provider.defaultModel;
273
+
274
+ if (provider.requiresApiKey) {
275
+ apiKeyGroup.classList.remove('hidden');
276
+ baseUrlGroup.classList.add('hidden');
277
+ apiKeyInput.value = '';
278
+ apiKeyInput.placeholder = provider.currentApiKeyMasked
279
+ ? 'Leave blank to keep current key'
280
+ : 'Paste API key';
281
+ apiKeyHint.textContent = provider.currentApiKeyMasked
282
+ ? `Current: ${provider.currentApiKeyMasked}`
283
+ : '';
284
+ } else {
285
+ apiKeyGroup.classList.add('hidden');
286
+ baseUrlGroup.classList.remove('hidden');
287
+ baseUrlInput.value = provider.currentBaseURL ?? provider.defaultBaseURL ?? '';
288
+ baseUrlInput.placeholder = provider.defaultBaseURL ?? 'http://localhost:8080';
289
+ }
290
+ }
291
+
292
+ // ── API calls ───────────────────────────────────────────────────────
293
+
294
+ async function loadProviders() {
295
+ try {
296
+ const res = await fetch('/api/voice/config/providers');
297
+ const data = await res.json();
298
+
299
+ allProviders = data.providers;
300
+ currentProviderId = data.current;
301
+
302
+ populateProviderDropdown();
303
+
304
+ const langSelect = $('#voice-language-select');
305
+ if (langSelect) langSelect.value = data.language;
306
+
307
+ const provider = getSelectedProvider();
308
+ if (provider) updateFormForProvider(provider);
309
+
310
+ const toolDesc = $('#tool-transcribe-desc');
311
+ if (toolDesc && provider) {
312
+ toolDesc.textContent = `Transcribe base64 audio via ${provider.displayName}`;
313
+ }
314
+ } catch {
315
+ updateStatusBadge(null);
316
+ }
317
+ }
318
+
319
+ async function fetchStatus() {
320
+ try {
321
+ const res = await fetch('/api/voice/config');
322
+ const cfg = await res.json();
323
+ updateStatusBadge(cfg.configured);
324
+ } catch {
325
+ updateStatusBadge(null);
326
+ }
327
+ }
328
+
329
+ async function fetchStats() {
330
+ if (!_container) return;
331
+ try {
332
+ const res = await fetch('/api/voice/config/stats');
333
+ const stats = await res.json();
334
+ const el = (id) => _container.querySelector('#' + id);
335
+ const totalEl = el('total-transcriptions');
336
+ if (totalEl) totalEl.textContent = stats.totalTranscriptions;
337
+ const durEl = el('total-duration');
338
+ if (durEl) durEl.textContent = stats.totalDurationSec + 's';
339
+ const errEl = el('total-errors');
340
+ if (errEl) errEl.textContent = stats.totalErrors;
341
+ const lastEl = el('last-transcription');
342
+ if (lastEl) lastEl.textContent = stats.lastTranscriptionAt
343
+ ? new Date(stats.lastTranscriptionAt).toLocaleTimeString()
344
+ : 'never';
345
+ } catch {
346
+ // ignore
347
+ }
348
+ }
349
+
350
+ // ── Microphone check ────────────────────────────────────────────────
351
+
352
+ async function checkMicrophone() {
353
+ if (!navigator.mediaDevices?.getUserMedia) {
354
+ showMicModal('Your browser does not support microphone access.');
355
+ return;
356
+ }
357
+ try {
358
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
359
+ stream.getTracks().forEach(t => t.stop());
360
+ } catch (err) {
361
+ let message;
362
+ if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
363
+ message = 'No microphone device was found. Connect a microphone and reload the page.';
364
+ } else if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
365
+ message = 'Microphone access was denied. Allow access in your browser settings and reload.';
366
+ } else if (err.name === 'NotReadableError') {
367
+ message = 'Microphone is in use by another application.';
368
+ } else {
369
+ message = err.message || 'Microphone is unavailable.';
370
+ }
371
+ showMicModal(message);
372
+ }
373
+ }
374
+
375
+ function showMicModal(message) {
376
+ const detail = $('#mic-modal-detail');
377
+ const modal = $('#mic-modal');
378
+ if (detail) detail.textContent = message;
379
+ if (modal) modal.classList.remove('hidden');
380
+ }
381
+
382
+ // ── Audio file handling ─────────────────────────────────────────────
383
+
384
+ function setAudioFile(file) {
385
+ audioFile = file;
386
+ const nameEl = $('#chosen-file-name');
387
+ if (nameEl) {
388
+ nameEl.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
389
+ nameEl.classList.remove('hidden');
390
+ }
391
+ const transcribeBtn = $('#transcribe-btn');
392
+ if (transcribeBtn) transcribeBtn.disabled = false;
393
+ const resultBox = $('#transcription-result');
394
+ if (resultBox) resultBox.classList.add('hidden');
395
+ const statusEl = $('#transcribe-status');
396
+ if (statusEl) statusEl.textContent = '';
397
+ }
398
+
399
+ // ── Event wiring ────────────────────────────────────────────────────
400
+
401
+ function wireEvents() {
402
+ if (!_container) return;
403
+
404
+ // Config form submit
405
+ const configForm = $('#config-form');
406
+ if (configForm) {
407
+ configForm.addEventListener('submit', async (e) => {
408
+ e.preventDefault();
409
+ const btn = $('#save-btn');
410
+ setLoading(btn, true);
411
+
412
+ const provider = getSelectedProvider();
413
+ const body = {
414
+ provider: currentProviderId,
415
+ language: $('#voice-language-select')?.value,
416
+ model: $('#voice-model-input')?.value.trim() || undefined,
417
+ };
418
+
419
+ if (provider?.requiresApiKey) {
420
+ const key = $('#voice-api-key-input')?.value.trim();
421
+ if (key) body.apiKey = key;
422
+ } else {
423
+ const url = $('#voice-base-url-input')?.value.trim();
424
+ if (url) body.baseURL = url;
425
+ }
426
+
427
+ try {
428
+ const res = await fetch('/api/voice/config', {
429
+ method: 'PUT',
430
+ headers: { 'Content-Type': 'application/json' },
431
+ body: JSON.stringify(body),
432
+ });
433
+ const data = await res.json();
434
+ if (data.ok) {
435
+ showToast('Configuration saved', 'ok');
436
+ const apiKeyInput = $('#voice-api-key-input');
437
+ if (apiKeyInput) apiKeyInput.value = '';
438
+ await loadProviders();
439
+ await fetchStatus();
440
+ } else {
441
+ showToast(data.error ?? 'Save failed', 'error');
442
+ }
443
+ } catch {
444
+ showToast('Network error', 'error');
445
+ } finally {
446
+ setLoading(btn, false);
447
+ }
448
+ });
449
+ }
450
+
451
+ // Provider change
452
+ const providerSelect = $('#voice-provider-select');
453
+ if (providerSelect) {
454
+ providerSelect.addEventListener('change', (e) => {
455
+ currentProviderId = e.target.value;
456
+ const provider = getSelectedProvider();
457
+ if (provider) updateFormForProvider(provider);
458
+ const testResult = $('#test-result');
459
+ if (testResult) testResult.textContent = '';
460
+ });
461
+ }
462
+
463
+ // Toggle API key visibility
464
+ const toggleKeyBtn = $('#toggle-key-btn');
465
+ if (toggleKeyBtn) {
466
+ toggleKeyBtn.addEventListener('click', () => {
467
+ const input = $('#voice-api-key-input');
468
+ const eye = $('#eye-icon');
469
+ const eyeOff = $('#eye-off-icon');
470
+ if (!input) return;
471
+ const isPassword = input.type === 'password';
472
+ input.type = isPassword ? 'text' : 'password';
473
+ if (eye) eye.classList.toggle('hidden', isPassword);
474
+ if (eyeOff) eyeOff.classList.toggle('hidden', !isPassword);
475
+ });
476
+ }
477
+
478
+ // Test connection
479
+ const testBtn = $('#test-btn');
480
+ if (testBtn) {
481
+ testBtn.addEventListener('click', async () => {
482
+ const resultEl = $('#test-result');
483
+ setLoading(testBtn, true);
484
+ if (resultEl) { resultEl.textContent = ''; resultEl.className = 'test-result'; }
485
+
486
+ const provider = getSelectedProvider();
487
+ const testBody = {
488
+ provider: currentProviderId,
489
+ model: $('#voice-model-input')?.value.trim() || undefined,
490
+ };
491
+ if (provider?.requiresApiKey) {
492
+ const key = $('#voice-api-key-input')?.value.trim();
493
+ if (key) testBody.apiKey = key;
494
+ } else {
495
+ const url = $('#voice-base-url-input')?.value.trim();
496
+ if (url) testBody.baseURL = url;
497
+ }
498
+
499
+ try {
500
+ const res = await fetch('/api/voice/config/test', {
501
+ method: 'POST',
502
+ headers: { 'Content-Type': 'application/json' },
503
+ body: JSON.stringify(testBody),
504
+ });
505
+ const data = await res.json();
506
+ if (data.ok) {
507
+ if (resultEl) { resultEl.textContent = `\u2713 ${data.displayName} is reachable`; resultEl.classList.add('test-result--ok'); }
508
+ } else {
509
+ if (resultEl) { resultEl.textContent = `\u2717 ${data.reason}`; resultEl.classList.add('test-result--error'); }
510
+ }
511
+ } catch {
512
+ if (resultEl) { resultEl.textContent = '\u2717 Network error'; resultEl.classList.add('test-result--error'); }
513
+ } finally {
514
+ setLoading(testBtn, false);
515
+ }
516
+ });
517
+ }
518
+
519
+ // Reset stats
520
+ const resetStatsBtn = $('#reset-stats-btn');
521
+ if (resetStatsBtn) {
522
+ resetStatsBtn.addEventListener('click', async () => {
523
+ setLoading(resetStatsBtn, true);
524
+ try {
525
+ await fetch('/api/voice/config/stats', { method: 'DELETE' });
526
+ await fetchStats();
527
+ showToast('Statistics reset', 'ok');
528
+ } catch {
529
+ showToast('Reset failed', 'error');
530
+ } finally {
531
+ setLoading(resetStatsBtn, false);
532
+ }
533
+ });
534
+ }
535
+
536
+ // File upload: browse button
537
+ const chooseFileBtn = $('#choose-file-btn');
538
+ const audioFileInput = $('#audio-file');
539
+ if (chooseFileBtn && audioFileInput) {
540
+ chooseFileBtn.addEventListener('click', () => audioFileInput.click());
541
+ }
542
+
543
+ // File upload: input change
544
+ if (audioFileInput) {
545
+ audioFileInput.addEventListener('change', () => {
546
+ const file = audioFileInput.files?.[0];
547
+ if (file) setAudioFile(file);
548
+ });
549
+ }
550
+
551
+ // File upload: drag and drop
552
+ const uploadArea = $('#upload-area');
553
+ if (uploadArea) {
554
+ uploadArea.addEventListener('dragover', (e) => {
555
+ e.preventDefault();
556
+ uploadArea.classList.add('upload-area--drag');
557
+ });
558
+ uploadArea.addEventListener('dragleave', () => {
559
+ uploadArea.classList.remove('upload-area--drag');
560
+ });
561
+ uploadArea.addEventListener('drop', (e) => {
562
+ e.preventDefault();
563
+ uploadArea.classList.remove('upload-area--drag');
564
+ const file = e.dataTransfer?.files?.[0];
565
+ if (file) {
566
+ if (!isAudioFile(file)) {
567
+ showToast('Invalid file type. Please drop an audio file.', 'error');
568
+ return;
569
+ }
570
+ setAudioFile(file);
571
+ }
572
+ });
573
+ }
574
+
575
+ // Transcribe button
576
+ const transcribeBtn = $('#transcribe-btn');
577
+ if (transcribeBtn) {
578
+ transcribeBtn.addEventListener('click', async () => {
579
+ if (!audioFile) return;
580
+ const statusEl = $('#transcribe-status');
581
+ const resultBox = $('#transcription-result');
582
+ setLoading(transcribeBtn, true);
583
+ if (statusEl) statusEl.textContent = 'Transcribing...';
584
+ if (resultBox) resultBox.classList.add('hidden');
585
+
586
+ try {
587
+ const buffer = await audioFile.arrayBuffer();
588
+ const base64 = arrayBufferToBase64(buffer);
589
+
590
+ const res = await fetch('/api/voice/config/test-transcription', {
591
+ method: 'POST',
592
+ headers: { 'Content-Type': 'application/json' },
593
+ body: JSON.stringify({ audioBase64: base64, filename: audioFile.name }),
594
+ });
595
+ const data = await res.json();
596
+
597
+ if (data.ok) {
598
+ const resultText = $('#result-text');
599
+ if (resultText) resultText.textContent = data.text;
600
+ const meta = [];
601
+ if (data.language) meta.push(`language: ${data.language}`);
602
+ if (data.duration != null) meta.push(`duration: ${data.duration.toFixed(1)}s`);
603
+ const resultMeta = $('#result-meta');
604
+ if (resultMeta) resultMeta.textContent = meta.join(' \u00b7 ');
605
+ if (resultBox) resultBox.classList.remove('hidden');
606
+ if (statusEl) statusEl.textContent = '';
607
+ await fetchStats();
608
+ } else {
609
+ if (statusEl) {
610
+ statusEl.textContent = `Error: ${data.error}`;
611
+ statusEl.style.color = 'var(--df-color-state-error)';
612
+ }
613
+ }
614
+ } catch {
615
+ if (statusEl) statusEl.textContent = 'Network error';
616
+ } finally {
617
+ setLoading(transcribeBtn, false);
618
+ }
619
+ });
620
+ }
621
+
622
+ // Mic modal dismiss
623
+ const micModalClose = $('#mic-modal-close');
624
+ if (micModalClose) {
625
+ micModalClose.addEventListener('click', () => {
626
+ const modal = $('#mic-modal');
627
+ if (modal) modal.classList.add('hidden');
628
+ });
629
+ }
630
+
631
+ // Escape key to close mic modal
632
+ _escapeHandler = (e) => {
633
+ if (e.key === 'Escape') {
634
+ const modal = $('#mic-modal');
635
+ if (modal && !modal.classList.contains('hidden')) {
636
+ modal.classList.add('hidden');
637
+ }
638
+ }
639
+ };
640
+ document.addEventListener('keydown', _escapeHandler);
641
+ }
642
+
643
+ // ── Stats polling ───────────────────────────────────────────────────
644
+
645
+ function startStatsPoll() {
646
+ if (_statsTimer) return;
647
+ fetchStats();
648
+ _statsTimer = setInterval(fetchStats, 5000);
649
+ }
650
+
651
+ function stopStatsPoll() {
652
+ if (_statsTimer) { clearInterval(_statsTimer); _statsTimer = null; }
653
+ }
654
+
655
+ // ── Lifecycle ───────────────────────────────────────────────────────
656
+
657
+ export function mount(container, ctx) {
658
+ _container = container;
659
+
660
+ // Reset module state
661
+ allProviders = [];
662
+ currentProviderId = 'openai';
663
+ audioFile = null;
664
+
665
+ // 1. Scope the container
666
+ container.classList.add('page-voice');
667
+
668
+ // 2. Build HTML
669
+ container.innerHTML = BODY_HTML;
670
+
671
+ // 3. Wire events
672
+ wireEvents();
673
+
674
+ // 4. Visibility change handler for stats polling
675
+ _visibilityHandler = () => {
676
+ if (document.hidden) stopStatsPoll();
677
+ else startStatsPoll();
678
+ };
679
+ document.addEventListener('visibilitychange', _visibilityHandler);
680
+
681
+ // 5. Initial data load
682
+ loadProviders();
683
+ fetchStatus();
684
+ startStatsPoll();
685
+ checkMicrophone();
686
+ }
687
+
688
+ export function unmount(container) {
689
+ // 1. Stop stats polling
690
+ stopStatsPoll();
691
+
692
+ // 2. Remove visibility handler
693
+ if (_visibilityHandler) {
694
+ document.removeEventListener('visibilitychange', _visibilityHandler);
695
+ _visibilityHandler = null;
696
+ }
697
+
698
+ // 3. Remove escape handler
699
+ if (_escapeHandler) {
700
+ document.removeEventListener('keydown', _escapeHandler);
701
+ _escapeHandler = null;
702
+ }
703
+
704
+ // 4. Remove scope class & clear HTML
705
+ if (container) {
706
+ container.classList.remove('page-voice');
707
+ container.innerHTML = '';
708
+ }
709
+
710
+ // 5. Clear module references
711
+ _container = null;
712
+ audioFile = null;
713
+ }
714
+
715
+ export function onProjectChange(project) {
716
+ // Voice config is global (not per-project), so no action needed.
717
+ // Kept for interface compliance.
718
+ }