@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- 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;
|