@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,349 @@
1
+ /**
2
+ * Realtime Voice Relay — WebSocket proxy to OpenAI Realtime API
3
+ *
4
+ * Accepts client WebSocket connections on `/api/realtime`, opens a
5
+ * corresponding WebSocket to `wss://api.openai.com/v1/realtime`,
6
+ * and bidirectionally relays all JSON events between the two.
7
+ *
8
+ * On connection, sends a `session.update` to OpenAI with:
9
+ * - voice from runtime config (`realtimeVoice`)
10
+ * - `semantic_vad` turn detection
11
+ * - PCM 24kHz audio format (input + output)
12
+ * - agent instructions (from the main agent's identity.theme or a default)
13
+ *
14
+ * Usage (in server.js):
15
+ * import { setupRealtimeRelay } from './server/realtime/index.js';
16
+ * setupRealtimeRelay(httpServer);
17
+ */
18
+
19
+ import { WebSocketServer, WebSocket } from 'ws';
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+ import os from 'os';
23
+ import { createLogger } from '../logger.js';
24
+ import { loadConfig } from '../runtime-config.js';
25
+ import { ALLOWED_ORIGINS } from '../config.js';
26
+
27
+ const log = createLogger('realtime');
28
+
29
+ // ─── Constants ──────────────────────────────────────────────────────────────
30
+ const OPENAI_REALTIME_BASE = 'wss://api.openai.com/v1/realtime';
31
+ const DEFAULT_MODEL = 'gpt-4o-mini-realtime-preview';
32
+
33
+ // Connection bookkeeping
34
+ const MAX_REALTIME_CONNECTIONS = 5;
35
+ const activeSessions = new Map(); // sessionId → { clientWs, openaiWs }
36
+
37
+ // ─── Helpers ────────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Read agent identity/theme from the OpenClaw config file.
41
+ * Falls back to a sensible default if anything goes wrong.
42
+ */
43
+ async function getAgentInstructions() {
44
+ const DEFAULT_INSTRUCTIONS =
45
+ 'You are a helpful voice assistant. Be concise, friendly, and conversational. ' +
46
+ 'Keep responses short since this is a voice conversation.';
47
+
48
+ try {
49
+ const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
50
+ const configPath = path.join(stateDir, 'openclaw.json');
51
+ const raw = await fs.readFile(configPath, 'utf8');
52
+ const config = JSON.parse(raw);
53
+
54
+ // Find the main agent (id === 'main') and pull identity.theme
55
+ const mainAgent = config.agents?.list?.find(a => a.id === 'main');
56
+ const name = mainAgent?.identity?.name;
57
+ const theme = mainAgent?.identity?.theme;
58
+
59
+ if (theme) {
60
+ return `Your name is ${name || 'Assistant'}. ${theme}. ` +
61
+ 'Keep responses concise — this is a real-time voice conversation.';
62
+ }
63
+ if (name) {
64
+ return `Your name is ${name}. ${DEFAULT_INSTRUCTIONS}`;
65
+ }
66
+ } catch {
67
+ // Config not found or unparseable — fall through to default
68
+ }
69
+
70
+ return DEFAULT_INSTRUCTIONS;
71
+ }
72
+
73
+ /**
74
+ * Verify that the WebSocket upgrade origin is allowed.
75
+ */
76
+ function verifyOrigin(origin) {
77
+ if (!origin) return true; // Non-browser clients don't send Origin
78
+ try {
79
+ const url = new URL(origin);
80
+ const host = url.hostname;
81
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
82
+ if (host.endsWith('.ts.net')) return true;
83
+ return ALLOWED_ORIGINS.some(allowed => {
84
+ try { return new URL(allowed).hostname === host; } catch { return false; }
85
+ });
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Build the `session.update` event sent to OpenAI on connection.
93
+ */
94
+ function buildSessionUpdate(voice, instructions) {
95
+ return JSON.stringify({
96
+ type: 'session.update',
97
+ session: {
98
+ modalities: ['text', 'audio'],
99
+ instructions,
100
+ voice: voice || 'marin',
101
+ input_audio_format: 'pcm16',
102
+ output_audio_format: 'pcm16',
103
+ input_audio_transcription: {
104
+ model: 'whisper-1',
105
+ },
106
+ turn_detection: {
107
+ type: 'server_vad',
108
+ threshold: 0.5,
109
+ prefix_padding_ms: 300,
110
+ silence_duration_ms: 500,
111
+ },
112
+ },
113
+ });
114
+ }
115
+
116
+ // ─── Public API ─────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Set up the Realtime API WebSocket relay on the given HTTP server.
120
+ *
121
+ * Listens for upgrade requests on `/api/realtime` using noServer mode
122
+ * (same pattern as gateway-proxy.js) so it coexists with the main
123
+ * WebSocket server and Gateway proxy.
124
+ *
125
+ * @param {import('http').Server} server - The HTTP server instance
126
+ */
127
+ export function setupRealtimeRelay(server) {
128
+ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
129
+
130
+ // ── Handle upgrade on /api/realtime ───────────────────────────────────
131
+ server.on('upgrade', (request, socket, head) => {
132
+ const { pathname } = new URL(request.url, `http://${request.headers.host}`);
133
+
134
+ if (pathname !== '/api/realtime') return; // Let other handlers take it
135
+
136
+ // Agent mode is handled by bridge.js's own upgrade handler
137
+ const url = new URL(request.url, `http://${request.headers.host}`);
138
+ if (url.searchParams.get('mode') === 'agent') return;
139
+
140
+ // Origin check
141
+ const origin = request.headers.origin;
142
+ if (!verifyOrigin(origin)) {
143
+ log.warn(`Rejected upgrade from invalid origin: ${origin}`);
144
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
145
+ socket.destroy();
146
+ return;
147
+ }
148
+
149
+ // Connection limit
150
+ if (activeSessions.size >= MAX_REALTIME_CONNECTIONS) {
151
+ log.warn(`Rejected upgrade — at capacity (${activeSessions.size}/${MAX_REALTIME_CONNECTIONS})`);
152
+ socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
153
+ socket.destroy();
154
+ return;
155
+ }
156
+
157
+ wss.handleUpgrade(request, socket, head, (ws) => {
158
+ wss.emit('connection', ws, request);
159
+ });
160
+ });
161
+
162
+ // ── New client connection ─────────────────────────────────────────────
163
+ wss.on('connection', async (clientWs, req) => {
164
+ const sessionId = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
165
+ const clientIp = req.socket.remoteAddress;
166
+ log.info(`Client connected: ${sessionId} from ${clientIp}`);
167
+
168
+ // ── Standalone mode (agent mode handled by bridge.js) ─────────────
169
+
170
+ // ── Read runtime config ─────────────────────────────────────────────
171
+ let config;
172
+ try {
173
+ config = await loadConfig();
174
+ } catch (err) {
175
+ log.error('Failed to load runtime config:', err);
176
+ clientWs.close(1011, 'Server configuration error');
177
+ return;
178
+ }
179
+
180
+ const apiKey = config.openaiApiKey;
181
+ if (!apiKey) {
182
+ log.error('No OpenAI API key configured — cannot open realtime session');
183
+ clientWs.send(JSON.stringify({
184
+ type: 'error',
185
+ error: 'No OpenAI API key configured. Add your key in Settings → Voice.',
186
+ }));
187
+ clientWs.close(1008, 'Missing API key');
188
+ return;
189
+ }
190
+
191
+ const model = config.realtimeModel || DEFAULT_MODEL;
192
+ const voice = config.realtimeVoice || 'marin';
193
+
194
+ // ── Open outbound WebSocket to OpenAI ───────────────────────────────
195
+ const openaiUrl = `${OPENAI_REALTIME_BASE}?model=${encodeURIComponent(model)}`;
196
+ let openaiWs;
197
+ try {
198
+ openaiWs = new WebSocket(openaiUrl, {
199
+ headers: {
200
+ 'Authorization': `Bearer ${apiKey}`,
201
+ 'OpenAI-Beta': 'realtime=v1',
202
+ },
203
+ perMessageDeflate: true,
204
+ });
205
+ } catch (err) {
206
+ log.error('Failed to create OpenAI WebSocket:', err);
207
+ clientWs.close(1011, 'Failed to connect to OpenAI');
208
+ return;
209
+ }
210
+
211
+ // Track session
212
+ let clientClosed = false;
213
+ let openaiClosed = false;
214
+ activeSessions.set(sessionId, { clientWs, openaiWs });
215
+
216
+ // Keepalive ping
217
+ const keepalive = setInterval(() => {
218
+ try {
219
+ if (clientWs.readyState === WebSocket.OPEN) clientWs.ping();
220
+ if (openaiWs.readyState === WebSocket.OPEN) openaiWs.ping();
221
+ } catch { /* swallow */ }
222
+ }, 15_000);
223
+
224
+ function cleanup() {
225
+ clearInterval(keepalive);
226
+ activeSessions.delete(sessionId);
227
+ if (!clientClosed && clientWs.readyState === WebSocket.OPEN) {
228
+ clientWs.close();
229
+ }
230
+ if (!openaiClosed && openaiWs.readyState === WebSocket.OPEN) {
231
+ openaiWs.close();
232
+ }
233
+ }
234
+
235
+ // ── OpenAI WebSocket events ─────────────────────────────────────────
236
+
237
+ openaiWs.on('open', async () => {
238
+ log.info(`OpenAI connected for session ${sessionId} (model=${model}, voice=${voice})`);
239
+
240
+ // Send session.update with agent instructions
241
+ try {
242
+ const instructions = await getAgentInstructions();
243
+ const sessionUpdate = buildSessionUpdate(voice, instructions);
244
+ log.info(`Sending session.update for ${sessionId}: ${sessionUpdate.substring(0, 500)}`);
245
+ openaiWs.send(sessionUpdate);
246
+ } catch (err) {
247
+ log.error(`Failed to send session.update for ${sessionId}:`, err);
248
+ }
249
+
250
+ // Notify client that the relay is ready
251
+ if (clientWs.readyState === WebSocket.OPEN) {
252
+ clientWs.send(JSON.stringify({ type: 'relay.ready' }));
253
+ }
254
+ });
255
+
256
+ openaiWs.on('message', (data, isBinary) => {
257
+ // Log OpenAI events for debugging
258
+ if (!isBinary) {
259
+ try {
260
+ const event = JSON.parse(data.toString());
261
+ if (event.type === 'error') {
262
+ log.error(`OpenAI error event for ${sessionId}:`, JSON.stringify(event));
263
+ } else {
264
+ log.info(`OpenAI event for ${sessionId}: ${event.type}`);
265
+ }
266
+ } catch { /* not JSON, skip */ }
267
+ }
268
+
269
+ // Forward OpenAI → Client
270
+ if (clientWs.readyState === WebSocket.OPEN) {
271
+ try {
272
+ if (isBinary) {
273
+ clientWs.send(data, { binary: true });
274
+ } else {
275
+ clientWs.send(data.toString());
276
+ }
277
+ } catch (err) {
278
+ log.error(`Error forwarding OpenAI → client (${sessionId}):`, err.message);
279
+ }
280
+ }
281
+ });
282
+
283
+ openaiWs.on('close', (code, reason) => {
284
+ openaiClosed = true;
285
+ const reasonStr = reason?.toString() || '';
286
+ log.info(`OpenAI disconnected for ${sessionId} (code=${code}${reasonStr ? ', reason=' + reasonStr : ''})`);
287
+ cleanup();
288
+ });
289
+
290
+ openaiWs.on('error', (err) => {
291
+ log.error(`OpenAI error for ${sessionId}:`, err.message);
292
+
293
+ // Forward a structured error to the client so it can display something useful
294
+ if (clientWs.readyState === WebSocket.OPEN) {
295
+ try {
296
+ clientWs.send(JSON.stringify({
297
+ type: 'error',
298
+ error: `OpenAI connection error: ${err.message}`,
299
+ }));
300
+ } catch { /* swallow */ }
301
+ }
302
+ cleanup();
303
+ });
304
+
305
+ // ── Client WebSocket events ─────────────────────────────────────────
306
+
307
+ clientWs.on('message', (data, isBinary) => {
308
+ // Log what the client is sending
309
+ if (!isBinary) {
310
+ try {
311
+ const clientMsg = JSON.parse(data.toString());
312
+ log.info(`Client event for ${sessionId}: ${clientMsg.type} (len=${data.length})`);
313
+ } catch { /* not JSON */ }
314
+ } else {
315
+ log.info(`Client binary for ${sessionId}: len=${data.length}`);
316
+ }
317
+
318
+ // Forward Client → OpenAI
319
+ // IMPORTANT: Send text frames as strings, not Buffers, so ws sends them
320
+ // as text frames (opcode 0x1) instead of binary frames (opcode 0x2).
321
+ // OpenAI Realtime API expects JSON as text frames.
322
+ if (openaiWs.readyState === WebSocket.OPEN) {
323
+ try {
324
+ if (isBinary) {
325
+ openaiWs.send(data);
326
+ } else {
327
+ openaiWs.send(data.toString());
328
+ }
329
+ } catch (err) {
330
+ log.error(`Error forwarding client → OpenAI (${sessionId}):`, err.message);
331
+ }
332
+ }
333
+ });
334
+
335
+ clientWs.on('close', (code) => {
336
+ clientClosed = true;
337
+ log.info(`Client disconnected for ${sessionId} (code=${code})`);
338
+ cleanup();
339
+ });
340
+
341
+ clientWs.on('error', (err) => {
342
+ log.error(`Client error for ${sessionId}:`, err.message);
343
+ cleanup();
344
+ });
345
+ });
346
+
347
+ log.info('Realtime voice relay ready at /api/realtime');
348
+ return wss;
349
+ }