@mooncompany/uplink-chat 0.5.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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,324 @@
1
+ // ============================================
2
+ // STREAMING HANDLER MODULE
3
+ // Shared SSE streaming, chunk processing, and dedup
4
+ // Used by both chat.js and split-chat.js
5
+ // ============================================
6
+
7
+ import { UplinkMarkdown } from './markdown.js';
8
+ import { UplinkMessageRenderer } from './message-renderer.js';
9
+
10
+ /**
11
+ * Create a new StreamingHandler instance.
12
+ * Each chat pane gets its own instance with independent state.
13
+ */
14
+ export function createStreamingHandler(options) {
15
+ const {
16
+ container,
17
+ formatMessage,
18
+ agentId = null,
19
+ onStreamStart,
20
+ onStreamEnd,
21
+ getIsNearBottom,
22
+ showAvatar = false
23
+ } = options;
24
+
25
+ // ============================================
26
+ // INSTANCE STATE
27
+ // ============================================
28
+
29
+ let isStreaming = false;
30
+ let streamingDiv = null;
31
+ let streamContent = '';
32
+ let isProcessingRequest = false;
33
+ const seenMessageIds = new Set();
34
+ const MAX_SEEN_IDS = 200;
35
+ let streamRenderTimer = null;
36
+ let streamRenderPending = null;
37
+ const STREAM_RENDER_INTERVAL_MS = 120;
38
+
39
+ // ============================================
40
+ // STREAMING MESSAGE MANAGEMENT
41
+ // ============================================
42
+
43
+ function createStreamingMessage() {
44
+ isStreaming = true;
45
+ streamContent = '';
46
+
47
+ const div = document.createElement('div');
48
+ div.className = 'message assistant streaming';
49
+ div.dataset.time = Date.now();
50
+
51
+ if (showAvatar) {
52
+ const avatar = UplinkMessageRenderer.buildAgentAvatar(agentId);
53
+ if (avatar) {
54
+ div.prepend(avatar);
55
+ }
56
+ }
57
+
58
+ const textSpan = document.createElement('span');
59
+ textSpan.className = 'message-text';
60
+ div.appendChild(textSpan);
61
+
62
+ if (container) {
63
+ container.appendChild(div);
64
+ container.scrollTop = container.scrollHeight;
65
+ }
66
+
67
+ streamingDiv = div;
68
+
69
+ if (onStreamStart) {
70
+ onStreamStart();
71
+ }
72
+
73
+ return div;
74
+ }
75
+
76
+ function updateStreamingContent(content) {
77
+ if (!streamingDiv) return;
78
+
79
+ const textSpan = streamingDiv.querySelector('.message-text');
80
+ if (!textSpan) return;
81
+
82
+ streamRenderPending = { textSpan, content };
83
+
84
+ if (!streamRenderTimer) {
85
+ flushStreamRender();
86
+ streamRenderTimer = setInterval(() => {
87
+ if (streamRenderPending) {
88
+ flushStreamRender();
89
+ } else {
90
+ clearStreamRenderTimer();
91
+ }
92
+ }, STREAM_RENDER_INTERVAL_MS);
93
+ }
94
+
95
+ if (container && (getIsNearBottom ? getIsNearBottom() : true)) {
96
+ container.scrollTop = container.scrollHeight;
97
+ }
98
+ }
99
+
100
+ function flushStreamRender() {
101
+ if (!streamRenderPending) return;
102
+ const { textSpan, content } = streamRenderPending;
103
+ streamRenderPending = null;
104
+
105
+ if (UplinkMarkdown?.render) {
106
+ textSpan.innerHTML = UplinkMarkdown.render(content);
107
+ if (UplinkMarkdown.highlightCode) {
108
+ UplinkMarkdown.highlightCode(textSpan);
109
+ }
110
+ } else if (formatMessage) {
111
+ textSpan.innerHTML = formatMessage(content);
112
+ } else {
113
+ textSpan.textContent = content;
114
+ }
115
+ }
116
+
117
+ function clearStreamRenderTimer() {
118
+ if (streamRenderTimer) {
119
+ clearInterval(streamRenderTimer);
120
+ streamRenderTimer = null;
121
+ }
122
+ streamRenderPending = null;
123
+ }
124
+
125
+ function finalizeStreamingMessage(finalContent) {
126
+ clearStreamRenderTimer();
127
+
128
+ const div = streamingDiv;
129
+ const content = finalContent || streamContent;
130
+
131
+ if (div) {
132
+ div.classList.remove('streaming');
133
+ div.dataset.originalText = content;
134
+
135
+ const textSpan = div.querySelector('.message-text');
136
+ if (textSpan && content) {
137
+ if (formatMessage) {
138
+ textSpan.innerHTML = formatMessage(content);
139
+ }
140
+ if (UplinkMarkdown?.highlightCode) {
141
+ UplinkMarkdown.highlightCode(textSpan);
142
+ }
143
+ }
144
+
145
+ if (container && (getIsNearBottom ? getIsNearBottom() : true)) {
146
+ container.scrollTop = container.scrollHeight;
147
+ }
148
+ }
149
+
150
+ streamingDiv = null;
151
+ streamContent = '';
152
+ isStreaming = false;
153
+
154
+ // Track finalization time for sync dedup (split-chat checks this)
155
+ instance._lastFinalizedAt = Date.now();
156
+
157
+ if (onStreamEnd) {
158
+ onStreamEnd();
159
+ }
160
+
161
+ return { div, content };
162
+ }
163
+
164
+ // ============================================
165
+ // SSE STREAM PROCESSING
166
+ // ============================================
167
+
168
+ function processChunk(parsed, callbacks = {}) {
169
+ if (parsed.status === 'thinking') {
170
+ if (!streamingDiv) {
171
+ createStreamingMessage();
172
+ }
173
+ updateStreamingContent('🧠 Thinking...');
174
+ if (callbacks.onThinking) callbacks.onThinking(parsed);
175
+ return;
176
+ }
177
+
178
+ if (parsed.tool) {
179
+ if (!streamingDiv) {
180
+ createStreamingMessage();
181
+ }
182
+ updateStreamingContent(`🔧 Using ${parsed.tool}...`);
183
+ if (callbacks.onTool) callbacks.onTool(parsed.tool);
184
+ return;
185
+ }
186
+
187
+ if (parsed.content) {
188
+ if (!streamingDiv) {
189
+ createStreamingMessage();
190
+ }
191
+ streamContent += parsed.content;
192
+ updateStreamingContent(streamContent);
193
+ }
194
+
195
+ if (parsed.done) {
196
+ const result = finalizeStreamingMessage();
197
+ if (callbacks.onDone) {
198
+ callbacks.onDone({
199
+ div: result.div,
200
+ fullResponse: result.content,
201
+ parsed
202
+ });
203
+ }
204
+ }
205
+
206
+ if (parsed.error) {
207
+ if (callbacks.onError) callbacks.onError(parsed.error || parsed.message || 'An error occurred');
208
+ }
209
+ }
210
+
211
+ async function processSSEStream(reader, callbacks = {}) {
212
+ const decoder = new TextDecoder();
213
+ let buffer = '';
214
+
215
+ while (true) {
216
+ const { done, value } = await reader.read();
217
+ if (done) break;
218
+
219
+ buffer += decoder.decode(value, { stream: true });
220
+ const lines = buffer.split('\n');
221
+ buffer = lines.pop() || '';
222
+
223
+ for (const line of lines) {
224
+ if (!line.startsWith('data: ')) continue;
225
+
226
+ const data = line.slice(6);
227
+ if (data === '[DONE]' || data.startsWith(':')) continue;
228
+
229
+ try {
230
+ const parsed = JSON.parse(data);
231
+ processChunk(parsed, callbacks);
232
+ await new Promise(r => setTimeout(r, 0));
233
+ } catch {
234
+ // Skip unparseable chunks
235
+ }
236
+ }
237
+ }
238
+
239
+ return {
240
+ fullResponse: streamContent,
241
+ streamingDiv
242
+ };
243
+ }
244
+
245
+ // ============================================
246
+ // MESSAGE DEDUP
247
+ // ============================================
248
+
249
+ function isDuplicate(messageId) {
250
+ if (!messageId) return false;
251
+ if (seenMessageIds.has(messageId)) return true;
252
+
253
+ seenMessageIds.add(messageId);
254
+ if (seenMessageIds.size > MAX_SEEN_IDS) {
255
+ const first = seenMessageIds.values().next().value;
256
+ seenMessageIds.delete(first);
257
+ }
258
+ return false;
259
+ }
260
+
261
+ function markSeen(messageId) {
262
+ if (!messageId) return;
263
+ seenMessageIds.add(messageId);
264
+ if (seenMessageIds.size > MAX_SEEN_IDS) {
265
+ const first = seenMessageIds.values().next().value;
266
+ seenMessageIds.delete(first);
267
+ }
268
+ }
269
+
270
+ // ============================================
271
+ // STATE ACCESSORS
272
+ // ============================================
273
+
274
+ function reset() {
275
+ clearStreamRenderTimer();
276
+ if (streamingDiv) {
277
+ finalizeStreamingMessage();
278
+ }
279
+ streamingDiv = null;
280
+ streamContent = '';
281
+ isStreaming = false;
282
+ isProcessingRequest = false;
283
+ }
284
+
285
+ // ============================================
286
+ // INSTANCE API
287
+ // ============================================
288
+
289
+ const instance = {
290
+ _lastFinalizedAt: 0,
291
+ createStreamingMessage,
292
+ updateStreamingContent,
293
+ finalizeStreamingMessage,
294
+ processChunk,
295
+ processSSEStream,
296
+ isDuplicate,
297
+ markSeen,
298
+ getIsStreaming: () => isStreaming,
299
+ getStreamingDiv: () => streamingDiv,
300
+ getStreamContent: () => streamContent,
301
+ setStreamContent: (c) => { streamContent = c; },
302
+ getIsProcessingRequest: () => isProcessingRequest,
303
+ setIsProcessingRequest: (v) => { isProcessingRequest = v; },
304
+ reset,
305
+ clearStreamRenderTimer
306
+ };
307
+
308
+ return instance;
309
+ }
310
+
311
+ // ============================================
312
+ // PUBLIC API
313
+ // ============================================
314
+
315
+ export const UplinkStreamingHandler = {
316
+ create: createStreamingHandler
317
+ };
318
+
319
+ // Backward compat: assign to window
320
+ window.UplinkStreamingHandler = UplinkStreamingHandler;
321
+
322
+ if (typeof logger !== 'undefined') {
323
+ logger.debug('StreamingHandler: Module loaded');
324
+ }
@@ -0,0 +1,316 @@
1
+ // ============================================
2
+ // STT SETTINGS MODULE
3
+ // Speech-to-Text configuration
4
+ // ============================================
5
+
6
+ // DOM elements
7
+ let sttProviderSelect, sttProviderDesc;
8
+ let groqKeyInput, groqKeySaveBtn, groqKeyStatus;
9
+ let groqSttModelSelect, openaiSttModelSelect;
10
+ let fasterWhisperUrlInput, fasterWhisperSaveBtn, fasterWhisperStatus;
11
+ let sttTestBtn, sttTestStatus, sttTestRow;
12
+
13
+ function init() {
14
+ sttProviderSelect = document.getElementById('sttProviderSelect');
15
+ sttProviderDesc = document.getElementById('sttProviderDesc');
16
+ groqKeyInput = document.getElementById('groqKeyInput');
17
+ groqKeySaveBtn = document.getElementById('groqKeySaveBtn');
18
+ groqKeyStatus = document.getElementById('groqKeyStatus');
19
+ groqSttModelSelect = document.getElementById('groqSttModelSelect');
20
+ openaiSttModelSelect = document.getElementById('openaiSttModelSelect');
21
+ fasterWhisperUrlInput = document.getElementById('fasterWhisperUrlInput');
22
+ fasterWhisperSaveBtn = document.getElementById('fasterWhisperSaveBtn');
23
+ fasterWhisperStatus = document.getElementById('fasterWhisperStatus');
24
+ sttTestBtn = document.getElementById('sttTestBtn');
25
+ sttTestStatus = document.getElementById('sttTestStatus');
26
+ sttTestRow = document.getElementById('sttTestRow');
27
+
28
+ setupEvents();
29
+ }
30
+
31
+ function setupEvents() {
32
+ // STT Provider change
33
+ sttProviderSelect?.addEventListener('change', async () => {
34
+ const newProvider = sttProviderSelect.value;
35
+
36
+ try {
37
+ const response = await fetch('/api/config', {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ sttProvider: newProvider })
41
+ });
42
+
43
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
44
+
45
+ // Update description
46
+ if (sttProviderDesc) {
47
+ const sttDescriptions = {
48
+ 'none': 'Speech recognition disabled',
49
+ 'openai': 'OpenAI Whisper (cloud, uses OpenAI API key)',
50
+ 'groq': 'Groq Whisper (cloud, free tier available)',
51
+ 'faster-whisper': 'Faster-Whisper (local server)'
52
+ };
53
+ sttProviderDesc.textContent = sttDescriptions[newProvider] || 'Speech recognition service';
54
+ }
55
+
56
+ updateSTTProviderUI(newProvider);
57
+ showToast('STT provider updated', 'success');
58
+ } catch (error) {
59
+ console.error('Failed to update STT provider:', error);
60
+ showToast('Failed to update STT provider', 'error');
61
+ fetchServerConfig(); // Revert on error
62
+ }
63
+ });
64
+
65
+ // Groq API key save
66
+ groqKeySaveBtn?.addEventListener('click', saveGroqKey);
67
+ groqKeyInput?.addEventListener('keypress', (e) => {
68
+ if (e.key === 'Enter') saveGroqKey();
69
+ });
70
+
71
+ // Groq STT model change
72
+ groqSttModelSelect?.addEventListener('change', async () => {
73
+ try {
74
+ const response = await fetch('/api/config', {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ groqSttModel: groqSttModelSelect.value })
78
+ });
79
+ if (response.ok) {
80
+ showToast('Groq STT model updated', 'success');
81
+ } else {
82
+ showToast('Failed to save model', 'error');
83
+ }
84
+ } catch (e) {
85
+ showToast('Failed to save Groq model', 'error');
86
+ }
87
+ });
88
+
89
+ // OpenAI STT model change
90
+ openaiSttModelSelect?.addEventListener('change', async () => {
91
+ try {
92
+ const response = await fetch('/api/config', {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({ openaiSttModel: openaiSttModelSelect.value })
96
+ });
97
+ if (response.ok) {
98
+ showToast('OpenAI STT model updated', 'success');
99
+ } else {
100
+ showToast('Failed to save model', 'error');
101
+ }
102
+ } catch (e) {
103
+ showToast('Failed to save OpenAI STT model', 'error');
104
+ }
105
+ });
106
+
107
+ // Faster-Whisper URL save
108
+ fasterWhisperSaveBtn?.addEventListener('click', saveFasterWhisperUrl);
109
+ fasterWhisperUrlInput?.addEventListener('keypress', (e) => {
110
+ if (e.key === 'Enter') saveFasterWhisperUrl();
111
+ });
112
+
113
+ // STT test
114
+ sttTestBtn?.addEventListener('click', testSTT);
115
+ }
116
+
117
+ /**
118
+ * Show config panel for the active STT provider, hide all others
119
+ */
120
+ function updateSTTProviderUI(provider) {
121
+ // Hide all stt-provider-config divs
122
+ document.querySelectorAll('.stt-provider-config').forEach(el => el.style.display = 'none');
123
+
124
+ // Show selected provider's config
125
+ const configEl = document.getElementById(`sttConfig-${provider}`);
126
+ if (configEl) configEl.style.display = 'block';
127
+
128
+ // Show/hide test row
129
+ if (sttTestRow) sttTestRow.style.display = provider !== 'none' ? 'flex' : 'none';
130
+ }
131
+
132
+ async function saveGroqKey() {
133
+ const apiKey = groqKeyInput?.value.trim();
134
+
135
+ if (!apiKey) {
136
+ if (groqKeyStatus) {
137
+ groqKeyStatus.textContent = 'Please enter an API key';
138
+ groqKeyStatus.style.color = 'var(--error-color, #ef4444)';
139
+ }
140
+ return;
141
+ }
142
+
143
+ if (groqKeyStatus) {
144
+ groqKeyStatus.textContent = 'Saving...';
145
+ groqKeyStatus.style.color = 'var(--text-muted)';
146
+ }
147
+
148
+ try {
149
+ const response = await fetch('/api/config', {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({ groqApiKey: apiKey })
153
+ });
154
+
155
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
156
+
157
+ if (groqKeyStatus) {
158
+ groqKeyStatus.textContent = '✓ API key saved';
159
+ groqKeyStatus.style.color = 'var(--success-color, #4ade80)';
160
+ }
161
+ if (groqKeyInput) {
162
+ groqKeyInput.value = '';
163
+ groqKeyInput.placeholder = '••••••••••••••••';
164
+ }
165
+ showToast('Groq API key saved', 'success');
166
+ } catch (error) {
167
+ console.error('Failed to save Groq key:', error);
168
+ if (groqKeyStatus) {
169
+ groqKeyStatus.textContent = 'Failed to save key';
170
+ groqKeyStatus.style.color = 'var(--error-color, #ef4444)';
171
+ }
172
+ showToast('Failed to save API key', 'error');
173
+ }
174
+ }
175
+
176
+ async function saveFasterWhisperUrl() {
177
+ const url = fasterWhisperUrlInput?.value.trim();
178
+
179
+ try {
180
+ const response = await fetch('/api/config', {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ fasterWhisperUrl: url || '' })
184
+ });
185
+
186
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
187
+
188
+ showToast(url ? 'Faster-Whisper URL saved' : 'Faster-Whisper URL cleared', 'success');
189
+ } catch (error) {
190
+ console.error('Failed to save Faster-Whisper URL:', error);
191
+ showToast('Failed to save URL', 'error');
192
+ }
193
+ }
194
+
195
+ async function testSTT() {
196
+ if (sttTestBtn) sttTestBtn.disabled = true;
197
+ if (sttTestStatus) {
198
+ sttTestStatus.textContent = 'Testing...';
199
+ sttTestStatus.style.color = 'var(--text-muted)';
200
+ }
201
+
202
+ try {
203
+ const response = await fetch('/api/stt/test', { method: 'POST' });
204
+ const data = await response.json();
205
+
206
+ if (data.success) {
207
+ if (sttTestStatus) {
208
+ sttTestStatus.textContent = `✓ ${data.message || 'STT is working'}`;
209
+ sttTestStatus.style.color = 'var(--success-color, #4ade80)';
210
+ }
211
+ showToast('STT test passed', 'success');
212
+ } else {
213
+ if (sttTestStatus) {
214
+ sttTestStatus.textContent = `✗ ${data.error || 'Test failed'}`;
215
+ sttTestStatus.style.color = 'var(--error-color, #ef4444)';
216
+ }
217
+ showToast('STT test failed', 'error');
218
+ }
219
+ } catch (error) {
220
+ console.error('STT test failed:', error);
221
+ if (sttTestStatus) {
222
+ sttTestStatus.textContent = 'Failed to run test';
223
+ sttTestStatus.style.color = 'var(--error-color, #ef4444)';
224
+ }
225
+ showToast('STT test failed', 'error');
226
+ } finally {
227
+ if (sttTestBtn) sttTestBtn.disabled = false;
228
+ }
229
+ }
230
+
231
+ async function fetchServerConfig() {
232
+ try {
233
+ const response = await fetch('/api/config');
234
+ if (!response.ok) return;
235
+
236
+ const config = await response.json();
237
+
238
+ // STT Provider
239
+ if (sttProviderSelect && config.sttProvider) {
240
+ sttProviderSelect.value = config.sttProvider;
241
+
242
+ // Update STT description
243
+ if (sttProviderDesc) {
244
+ const sttDescriptions = {
245
+ 'none': 'Speech recognition disabled',
246
+ 'openai': 'OpenAI Whisper (cloud, uses OpenAI API key)',
247
+ 'groq': 'Groq Whisper (cloud, free tier available)',
248
+ 'faster-whisper': 'Faster-Whisper (local server)'
249
+ };
250
+ sttProviderDesc.textContent = sttDescriptions[config.sttProvider] || 'Speech recognition service';
251
+ }
252
+
253
+ // Show config for active STT provider
254
+ updateSTTProviderUI(config.sttProvider);
255
+ }
256
+
257
+ // Populate STT fields
258
+ if (openaiSttModelSelect && config.openaiSttModel) {
259
+ openaiSttModelSelect.value = config.openaiSttModel;
260
+ }
261
+ if (groqSttModelSelect && config.groqSttModel) {
262
+ groqSttModelSelect.value = config.groqSttModel;
263
+ }
264
+ if (fasterWhisperUrlInput && config.fasterWhisperUrl) {
265
+ fasterWhisperUrlInput.value = config.fasterWhisperUrl;
266
+ }
267
+
268
+ // Update OpenAI STT key status
269
+ const openaiSttKeyDesc = document.getElementById('openaiSttKeyDesc');
270
+ if (openaiSttKeyDesc) {
271
+ if (config.hasOpenaiKey) {
272
+ openaiSttKeyDesc.textContent = '✓ Using OpenAI key from TTS settings';
273
+ openaiSttKeyDesc.style.color = 'var(--success-color, #4ade80)';
274
+ } else {
275
+ openaiSttKeyDesc.textContent = 'No OpenAI key configured — add one in TTS settings';
276
+ openaiSttKeyDesc.style.color = 'var(--error-color, #ef4444)';
277
+ }
278
+ }
279
+
280
+ // Update Groq key status
281
+ if (groqKeyStatus && config.hasGroqKey) {
282
+ groqKeyStatus.textContent = '✓ API key configured';
283
+ groqKeyStatus.style.color = 'var(--success-color, #4ade80)';
284
+ if (groqKeyInput) groqKeyInput.placeholder = '••••••••••••••••';
285
+ }
286
+
287
+ // Update Faster-Whisper detection status
288
+ if (fasterWhisperStatus && config.fasterWhisperDetected) {
289
+ fasterWhisperStatus.textContent = '✓ Server detected at localhost:8000';
290
+ fasterWhisperStatus.style.color = 'var(--success-color, #4ade80)';
291
+ }
292
+ } catch (error) {
293
+ console.warn('STT Settings: Failed to fetch server config', error);
294
+ }
295
+ }
296
+
297
+ function showToast(message, type = 'success') {
298
+ if (window.UplinkSettings?.showToast) {
299
+ window.UplinkSettings.showToast(message, type);
300
+ }
301
+ }
302
+
303
+ function applyState() {
304
+ fetchServerConfig();
305
+ }
306
+
307
+ // Expose API
308
+ export const UplinkSTTSettings = {
309
+ init,
310
+ applyState,
311
+ fetchServerConfig,
312
+ updateSTTProviderUI
313
+ };
314
+
315
+ // Backward compat: assign to window
316
+ window.UplinkSTTSettings = UplinkSTTSettings;