@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,174 @@
1
+ /**
2
+ * Artifacts Routes - Read-only API for artifacts directory
3
+ * Provides file listing and content reading for agent-generated documents
4
+ */
5
+
6
+ import fs from 'fs/promises';
7
+ import fsSync from 'fs';
8
+ import path from 'path';
9
+ import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
10
+
11
+ /**
12
+ * Setup artifacts routes
13
+ * @param {Express} app - Express app instance
14
+ * @param {Object} context - Request context
15
+ */
16
+ export function setupArtifactsRoutes(app, context) {
17
+ const { log, config } = context;
18
+
19
+ // Artifacts directory — check multiple locations:
20
+ // 1. ARTIFACTS_DIR env var (explicit override)
21
+ // 2. Workspace root (parent of uplink dir) — common for OpenClaw workspace layout
22
+ // 3. Uplink server directory itself (fallback)
23
+ const rootDir = config.ROOT_DIR || process.cwd();
24
+ const candidates = [
25
+ process.env.ARTIFACTS_DIR,
26
+ path.join(path.dirname(rootDir), 'artifacts'),
27
+ path.join(rootDir, 'artifacts'),
28
+ ].filter(Boolean);
29
+
30
+ let artifactsDir = candidates[candidates.length - 1]; // default to last
31
+ for (const candidate of candidates) {
32
+ try {
33
+ const stat = fsSync.statSync(candidate);
34
+ if (stat.isDirectory()) {
35
+ artifactsDir = candidate;
36
+ break;
37
+ }
38
+ } catch { /* continue */ }
39
+ }
40
+
41
+ // Allowed file extensions for security (read-only access to text/doc files)
42
+ const ALLOWED_EXTENSIONS = [
43
+ '.md', '.txt', '.html', '.json', '.csv',
44
+ '.yml', '.yaml', '.xml', '.log'
45
+ ];
46
+
47
+ /**
48
+ * Validate filename - prevent path traversal and restrict to allowed extensions
49
+ */
50
+ function validateFilename(filename) {
51
+ if (!filename || typeof filename !== 'string') {
52
+ return { valid: false, error: 'Invalid filename' };
53
+ }
54
+
55
+ // Security: No path separators, no parent directory refs
56
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
57
+ return { valid: false, error: 'Invalid characters in filename' };
58
+ }
59
+
60
+ // Security: Must have an allowed extension
61
+ const ext = path.extname(filename).toLowerCase();
62
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
63
+ return { valid: false, error: 'File type not allowed' };
64
+ }
65
+
66
+ // Additional security: Restrict to alphanumeric, dash, underscore, dot
67
+ if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
68
+ return { valid: false, error: 'Invalid filename format' };
69
+ }
70
+
71
+ return { valid: true };
72
+ }
73
+
74
+ // ===========================================
75
+ // GET /api/artifacts
76
+ // List all artifacts in the directory
77
+ // ===========================================
78
+
79
+ app.get('/api/artifacts', async (req, res) => {
80
+ try {
81
+ // Check if directory exists
82
+ const stat = await fs.stat(artifactsDir).catch(() => null);
83
+ if (!stat || !stat.isDirectory()) {
84
+ // Empty list if directory doesn't exist yet
85
+ return res.json([]);
86
+ }
87
+
88
+ // Read directory
89
+ const files = await fs.readdir(artifactsDir);
90
+
91
+ // Filter to allowed types and gather metadata
92
+ const artifacts = [];
93
+ for (const filename of files) {
94
+ const validation = validateFilename(filename);
95
+ if (!validation.valid) continue;
96
+
97
+ const filePath = path.join(artifactsDir, filename);
98
+ const stat = await fs.stat(filePath).catch(() => null);
99
+
100
+ if (!stat || !stat.isFile()) continue;
101
+
102
+ artifacts.push({
103
+ name: filename,
104
+ size: stat.size,
105
+ modified: stat.mtime.toISOString(),
106
+ extension: path.extname(filename).toLowerCase()
107
+ });
108
+ }
109
+
110
+ // Sort by modified date (newest first)
111
+ artifacts.sort((a, b) => new Date(b.modified) - new Date(a.modified));
112
+
113
+ res.json(artifacts);
114
+
115
+ } catch (error) {
116
+ log('error', '[Artifacts] List error:', error);
117
+ internalError(res, 'Failed to list artifacts', ErrorCodes.INTERNAL_ERROR);
118
+ }
119
+ });
120
+
121
+ // ===========================================
122
+ // GET /api/artifacts/:filename
123
+ // Read artifact file contents
124
+ // ===========================================
125
+
126
+ app.get('/api/artifacts/:filename', async (req, res) => {
127
+ try {
128
+ const { filename } = req.params;
129
+
130
+ // Validate filename
131
+ const validation = validateFilename(filename);
132
+ if (!validation.valid) {
133
+ return badRequest(res, validation.error, ErrorCodes.INVALID_FILE_TYPE);
134
+ }
135
+
136
+ const filePath = path.join(artifactsDir, filename);
137
+
138
+ // Security: Double-check the resolved path is within artifacts directory
139
+ const resolvedPath = path.resolve(filePath);
140
+ const resolvedDir = path.resolve(artifactsDir);
141
+ if (!resolvedPath.startsWith(resolvedDir)) {
142
+ log('warn', `[Artifacts] Path traversal attempt: ${filename}`);
143
+ return badRequest(res, 'Invalid file path', ErrorCodes.INVALID_FILE_TYPE);
144
+ }
145
+
146
+ // Check file exists
147
+ const stat = await fs.stat(filePath).catch(() => null);
148
+ if (!stat || !stat.isFile()) {
149
+ return res.status(404).json({ error: 'File not found' });
150
+ }
151
+
152
+ // Read file content
153
+ const content = await fs.readFile(filePath, 'utf-8');
154
+
155
+ // Return content with metadata
156
+ res.json({
157
+ name: filename,
158
+ content,
159
+ size: stat.size,
160
+ modified: stat.mtime.toISOString(),
161
+ extension: path.extname(filename).toLowerCase()
162
+ });
163
+
164
+ } catch (error) {
165
+ log('error', '[Artifacts] Read error:', error);
166
+
167
+ if (error.code === 'ENOENT') {
168
+ return res.status(404).json({ error: 'File not found' });
169
+ }
170
+
171
+ internalError(res, 'Failed to read artifact', ErrorCodes.INTERNAL_ERROR);
172
+ }
173
+ });
174
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Chat Routes - Text chat and streaming endpoints
3
+ */
4
+
5
+ import { MAX_INPUT_LENGTHS, SSE_KEEPALIVE_INTERVAL_MS } from '../config.js';
6
+ import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
7
+ import { broadcastSyncMessage, generateMessageId, broadcastSyncThinking, broadcastSyncDelta, broadcastSyncTool, broadcastSyncComplete, cleanupSyncDeltaThrottle } from '../websocket/index.js';
8
+ import { isGatewayCommand, sendGatewayCommand } from '../gateway-commands.js';
9
+
10
+ /**
11
+ * Validate input length and return error if exceeded
12
+ * @param {string} value - Input value to validate
13
+ * @param {number} maxLength - Maximum allowed length
14
+ * @param {string} fieldName - Field name for error message
15
+ * @returns {string|null} Error message or null if valid
16
+ */
17
+ function validateLength(value, maxLength, fieldName) {
18
+ if (value && value.length > maxLength) {
19
+ return `${fieldName} exceeds maximum length of ${maxLength} characters`;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Setup chat routes
26
+ * @param {Express} app - Express app instance
27
+ * @param {Object} context - Request context
28
+ */
29
+ export function setupChatRoutes(app, context) {
30
+ const {
31
+ chat,
32
+ chatWithParallelTTS,
33
+ generateTTS,
34
+ sendMessage,
35
+ saveMessageToSync,
36
+ log,
37
+ config,
38
+ } = context;
39
+
40
+ const { SESSION_USER } = config;
41
+
42
+ // ===========================================
43
+ // Text Chat (Streaming)
44
+ // ===========================================
45
+
46
+ app.post('/api/chat', async (req, res) => {
47
+ const { message, mode = 'text', stream = false, satelliteId: rawSatelliteId = 'main', satelliteName: rawSatelliteName, agentId: rawAgentId } = req.body;
48
+
49
+ if (!message) {
50
+ return badRequest(res, 'No message provided', ErrorCodes.MISSING_FIELD);
51
+ }
52
+
53
+ const lengthError = validateLength(message, MAX_INPUT_LENGTHS.MESSAGE, 'Message');
54
+ if (lengthError) return badRequest(res, lengthError, ErrorCodes.VALIDATION_ERROR);
55
+
56
+ // Validate satelliteId
57
+ const satelliteId = String(rawSatelliteId).replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'main';
58
+
59
+ // Sanitize satellite name (for session label)
60
+ const satelliteName = rawSatelliteName ? String(rawSatelliteName).substring(0, 64) : null;
61
+
62
+ // Validate agentId (optional — defaults to 'main')
63
+ const agentId = rawAgentId ? String(rawAgentId).replace(/[^a-z0-9-]/g, '').substring(0, 64) || 'main' : 'main';
64
+
65
+ if (satelliteId !== rawSatelliteId) {
66
+ log('warn', `[Chat] Sanitized satelliteId: "${rawSatelliteId}" → "${satelliteId}"`);
67
+ }
68
+
69
+ log('debug', `[${mode === 'voice' ? 'Voice' : 'Text'}] [satellite:${satelliteId}] [agent:${agentId}] [label:${satelliteName}] "${message}"`);
70
+ await saveMessageToSync('user', message);
71
+
72
+ // Broadcast user message to WebSocket clients for cross-device sync
73
+ const userMessageId = generateMessageId();
74
+ broadcastSyncMessage('user', message, satelliteId, userMessageId);
75
+
76
+ // Intercept gateway slash commands — send via WebSocket instead of HTTP API
77
+ // The /v1/chat/completions endpoint doesn't process slash commands
78
+ if (isGatewayCommand(message)) {
79
+ log('info', `[Chat] Gateway command detected: ${message}`);
80
+ try {
81
+ const sessionKey = satelliteId === 'main'
82
+ ? `agent:${agentId}:main`
83
+ : `agent:${agentId}:uplink:satellite:${satelliteId}`;
84
+ const result = await sendGatewayCommand(message, sessionKey);
85
+
86
+ if (stream) {
87
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
88
+ res.setHeader('Cache-Control', 'no-cache');
89
+ res.setHeader('Connection', 'keep-alive');
90
+ res.flushHeaders();
91
+
92
+ if (result.response) {
93
+ res.write(`data: ${JSON.stringify({ content: result.response })}\n\n`);
94
+ await saveMessageToSync('assistant', result.response);
95
+ const assistantMessageId = generateMessageId();
96
+ broadcastSyncMessage('assistant', result.response, satelliteId, assistantMessageId);
97
+ }
98
+ res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
99
+ res.write(`data: [DONE]\n\n`);
100
+ res.end();
101
+ } else {
102
+ if (result.response) {
103
+ await saveMessageToSync('assistant', result.response);
104
+ const assistantMessageId = generateMessageId();
105
+ broadcastSyncMessage('assistant', result.response, satelliteId, assistantMessageId);
106
+ }
107
+ res.json({ response: result.response || 'Command executed.' });
108
+ }
109
+ } catch (err) {
110
+ log('error', `[Chat] Gateway command error: ${err.message}`);
111
+ // Always return generic error to client; detailed error logged server-side
112
+ const genericMessage = 'Command execution failed';
113
+ if (stream) {
114
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
115
+ res.setHeader('Cache-Control', 'no-cache');
116
+ res.flushHeaders();
117
+ res.write(`data: ${JSON.stringify({ error: true, message: genericMessage })}\n\n`);
118
+ res.end();
119
+ } else {
120
+ internalError(res, genericMessage, ErrorCodes.INTERNAL_ERROR);
121
+ }
122
+ }
123
+ return;
124
+ }
125
+
126
+ // Streaming SSE response
127
+ if (stream && mode === 'text') {
128
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
129
+ res.setHeader('Cache-Control', 'no-cache');
130
+ res.setHeader('Connection', 'keep-alive');
131
+ res.setHeader('X-Accel-Buffering', 'no');
132
+ res.flushHeaders();
133
+
134
+ // Track disconnect state and create abort controller for cleanup
135
+ const abortController = new AbortController();
136
+ let clientDisconnected = false;
137
+
138
+ const keepAlive = setInterval(() => {
139
+ if (!clientDisconnected) {
140
+ res.write(`: keepalive\n\n`);
141
+ }
142
+ }, SSE_KEEPALIVE_INTERVAL_MS);
143
+
144
+ // Cleanup function to handle disconnect
145
+ const cleanup = () => {
146
+ clientDisconnected = true;
147
+ abortController.abort();
148
+ clearInterval(keepAlive);
149
+ };
150
+
151
+ // Listen for client disconnect
152
+ req.on('close', () => {
153
+ if (!clientDisconnected) {
154
+ log('debug', '[Stream] Client disconnected mid-stream');
155
+ cleanup();
156
+ }
157
+ });
158
+
159
+ // Safe write helper - only write if client still connected
160
+ const safeWrite = (data) => {
161
+ if (!clientDisconnected && !res.writableEnded) {
162
+ try {
163
+ res.write(data);
164
+ } catch (e) {
165
+ // Client disconnected during write
166
+ cleanup();
167
+ }
168
+ }
169
+ };
170
+
171
+ // Generate request ID for correlating sync stream deltas with final message
172
+ const syncRequestId = generateMessageId();
173
+
174
+ try {
175
+ // Use unified sendMessage (routes through channel if enabled)
176
+ const result = await sendMessage({
177
+ message,
178
+ satelliteId,
179
+ satelliteName,
180
+ agentId,
181
+ mode,
182
+ signal: abortController.signal,
183
+ onThinking: () => {
184
+ safeWrite(`data: ${JSON.stringify({ status: 'thinking' })}\n\n`);
185
+ broadcastSyncThinking(syncRequestId, satelliteId);
186
+ },
187
+ onChunk: (content) => {
188
+ safeWrite(`data: ${JSON.stringify({ content })}\n\n`);
189
+ broadcastSyncDelta(syncRequestId, content, satelliteId);
190
+ },
191
+ onTool: (tool) => {
192
+ safeWrite(`data: ${JSON.stringify({ tool })}\n\n`);
193
+ broadcastSyncTool(syncRequestId, tool, satelliteId);
194
+ },
195
+ });
196
+
197
+ // Only complete if client still connected
198
+ if (!clientDisconnected) {
199
+ // Process media references if present
200
+ let mediaUrls = null;
201
+ if (result.media && result.media.length > 0 && app.registerAgentMedia) {
202
+ mediaUrls = result.media
203
+ .map(filePath => app.registerAgentMedia(filePath))
204
+ .filter(url => url !== null);
205
+
206
+ if (mediaUrls.length > 0) {
207
+ log('debug', `[Media] Registered ${mediaUrls.length} file(s)`);
208
+ }
209
+ }
210
+
211
+ if (result.response) {
212
+ await saveMessageToSync('assistant', result.response);
213
+
214
+ // Flush remaining deltas before sending final sync message
215
+ cleanupSyncDeltaThrottle(syncRequestId);
216
+
217
+ // Broadcast assistant response to WebSocket clients for cross-device sync
218
+ const assistantMessageId = generateMessageId();
219
+ broadcastSyncMessage('assistant', result.response, satelliteId, assistantMessageId, null, syncRequestId);
220
+ }
221
+
222
+ // Broadcast usage stats via WebSocket for WS-streaming clients
223
+ broadcastSyncComplete(syncRequestId, result.usage, satelliteId);
224
+
225
+ safeWrite(`data: ${JSON.stringify({
226
+ done: true,
227
+ usage: result.usage,
228
+ tools: result.tools,
229
+ media: mediaUrls
230
+ })}\n\n`);
231
+ safeWrite(`data: [DONE]\n\n`);
232
+
233
+ log('debug', `[Response] "${result.response.substring(0, 100)}..." tokens: ${result.usage?.total_tokens || '?'}${result.usage?.estimated ? ' (est)' : ''}`);
234
+ }
235
+
236
+ cleanup();
237
+ if (!res.writableEnded) {
238
+ res.end();
239
+ }
240
+
241
+ } catch (error) {
242
+ cleanupSyncDeltaThrottle(syncRequestId);
243
+ cleanup();
244
+
245
+ // Don't log or send error if client disconnected (AbortError or write failure)
246
+ if (clientDisconnected || error.name === 'AbortError') {
247
+ log('debug', '[Stream] Request aborted due to client disconnect');
248
+ } else {
249
+ log('error', 'Stream error:', error);
250
+ if (!res.writableEnded) {
251
+ try {
252
+ // Always return generic error to client; detailed error logged server-side
253
+ const genericMessage = 'Unable to reach the AI service';
254
+ res.write(`data: ${JSON.stringify({ error: true, message: genericMessage })}\n\n`);
255
+ } catch (e) {
256
+ // Ignore write errors during error handling
257
+ }
258
+ }
259
+ }
260
+
261
+ if (!res.writableEnded) {
262
+ res.end();
263
+ }
264
+ }
265
+ return;
266
+ }
267
+
268
+ // Non-streaming fallback - use unified sendMessage for session sync
269
+ try {
270
+ let response = '';
271
+ let audioUrl = null;
272
+ let audioUrls = [];
273
+
274
+ if (mode === 'voice') {
275
+ try {
276
+ const result = await chatWithParallelTTS(message, SESSION_USER);
277
+ response = result.response;
278
+ audioUrl = result.audioUrl;
279
+ audioUrls = result.audioUrls || [];
280
+ } catch (parallelError) {
281
+ log('warn', 'Parallel TTS failed, falling back:', parallelError.message);
282
+ // Use sendMessage for consistent session key handling
283
+ const result = await sendMessage({ message, satelliteId, satelliteName, agentId, mode });
284
+ response = result.response;
285
+ try {
286
+ audioUrl = await generateTTS(response);
287
+ audioUrls = audioUrl ? [audioUrl] : [];
288
+ } catch (e) {
289
+ log('error', 'TTS failed:', e.message);
290
+ }
291
+ }
292
+ } else {
293
+ // Use sendMessage for consistent session key handling (session sync)
294
+ const result = await sendMessage({ message, satelliteId, satelliteName, agentId, mode });
295
+ response = result.response;
296
+ }
297
+
298
+ log('debug', `[Response] "${response.substring(0, 100)}..."`);
299
+ await saveMessageToSync('assistant', response);
300
+
301
+ // Broadcast assistant response to WebSocket clients for cross-device sync
302
+ const assistantMsgId = generateMessageId();
303
+ broadcastSyncMessage('assistant', response, satelliteId, assistantMsgId);
304
+
305
+ res.json({ response, audioUrl, audioUrls });
306
+ } catch (error) {
307
+ log('error', 'Chat error:', error);
308
+ internalError(res, error.message, ErrorCodes.INTERNAL_ERROR);
309
+ }
310
+ });
311
+ }