@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,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
|
+
}
|