@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,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Module - OpenClaw channel webhook integration
|
|
3
|
+
*
|
|
4
|
+
* Routes messages through the OpenClaw channel plugin for proper
|
|
5
|
+
* session isolation and unified message handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { log, fetchWithTimeout } from './utils.js';
|
|
9
|
+
import { isGatewayCommand as isGatewayCommandUtil } from './gateway-commands.js';
|
|
10
|
+
import {
|
|
11
|
+
OPENCLAW_WEBHOOK_URL,
|
|
12
|
+
OPENCLAW_CALLBACK_SECRET,
|
|
13
|
+
USE_CHANNEL_WEBHOOK,
|
|
14
|
+
GATEWAY_URL as STATIC_GATEWAY_URL,
|
|
15
|
+
GATEWAY_TOKEN as STATIC_GATEWAY_TOKEN,
|
|
16
|
+
SESSION_USER,
|
|
17
|
+
CHANNEL_FETCH_TIMEOUT_MS,
|
|
18
|
+
STREAM_READ_TIMEOUT_MS
|
|
19
|
+
} from './config.js';
|
|
20
|
+
import { loadConfig } from './runtime-config.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get gateway URL and token, preferring runtime config (which includes auto-discovery)
|
|
24
|
+
* over static config.js values. This is needed because config.js exports are set at
|
|
25
|
+
* module load time and don't reflect auto-discovered values.
|
|
26
|
+
*/
|
|
27
|
+
async function getGatewayConfig() {
|
|
28
|
+
try {
|
|
29
|
+
const config = await loadConfig();
|
|
30
|
+
return {
|
|
31
|
+
url: config.gatewayUrl || STATIC_GATEWAY_URL,
|
|
32
|
+
token: config.gatewayToken || STATIC_GATEWAY_TOKEN,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
// Fallback to static config if runtime config fails
|
|
36
|
+
return { url: STATIC_GATEWAY_URL, token: STATIC_GATEWAY_TOKEN };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if channel webhook routing is enabled and configured
|
|
42
|
+
*/
|
|
43
|
+
export function isChannelEnabled() {
|
|
44
|
+
return USE_CHANNEL_WEBHOOK && OPENCLAW_WEBHOOK_URL && OPENCLAW_CALLBACK_SECRET;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Send a message through the OpenClaw channel webhook
|
|
49
|
+
* Returns an async generator that yields stream chunks
|
|
50
|
+
*
|
|
51
|
+
* @param {Object} params
|
|
52
|
+
* @param {string} params.message - The message to send
|
|
53
|
+
* @param {string} params.satelliteId - Satellite ID for session isolation
|
|
54
|
+
* @param {string} params.mode - 'text' or 'voice'
|
|
55
|
+
* @param {string} params.requestId - Optional request ID for tracking
|
|
56
|
+
* @param {string} params.userId - Optional user ID
|
|
57
|
+
*/
|
|
58
|
+
export async function* sendViaChannel({ message, satelliteId = 'main', mode = 'text', requestId, userId = 'default' }) {
|
|
59
|
+
if (!isChannelEnabled()) {
|
|
60
|
+
throw new Error('Channel webhook not configured');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
log('debug', `[Channel] Sending to webhook: satellite=${satelliteId}, mode=${mode}`);
|
|
64
|
+
|
|
65
|
+
const response = await fetchWithTimeout(OPENCLAW_WEBHOOK_URL, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'X-Uplink-Secret': OPENCLAW_CALLBACK_SECRET,
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
message,
|
|
73
|
+
satelliteId,
|
|
74
|
+
mode,
|
|
75
|
+
requestId,
|
|
76
|
+
userId,
|
|
77
|
+
}),
|
|
78
|
+
}, CHANNEL_FETCH_TIMEOUT_MS);
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
throw new Error(`Channel webhook error: ${response.status} - ${text}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parse SSE stream
|
|
86
|
+
const reader = response.body.getReader();
|
|
87
|
+
const decoder = new TextDecoder();
|
|
88
|
+
let buffer = '';
|
|
89
|
+
|
|
90
|
+
// Helper to read with timeout (prevents hanging on stalled streams)
|
|
91
|
+
const readWithTimeout = (timeoutMs = STREAM_READ_TIMEOUT_MS) => {
|
|
92
|
+
return Promise.race([
|
|
93
|
+
reader.read(),
|
|
94
|
+
new Promise((_, reject) =>
|
|
95
|
+
setTimeout(() => reject(new Error('Stream read timed out - gateway may be stalled')), timeoutMs)
|
|
96
|
+
),
|
|
97
|
+
]);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
while (true) {
|
|
102
|
+
const { done, value } = await readWithTimeout();
|
|
103
|
+
if (done) break;
|
|
104
|
+
|
|
105
|
+
buffer += decoder.decode(value, { stream: true });
|
|
106
|
+
const lines = buffer.split('\n');
|
|
107
|
+
buffer = lines.pop() || '';
|
|
108
|
+
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (line.startsWith('data: ')) {
|
|
111
|
+
const data = line.slice(6);
|
|
112
|
+
if (data === '[DONE]') {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(data);
|
|
118
|
+
yield parsed; // { type: 'thinking' | 'chunk' | 'tool' | 'complete' | 'error', ... }
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip unparseable chunks
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
// Ensure reader is released on timeout or error
|
|
127
|
+
reader.releaseLock();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// sendMessage Helper Functions
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create a reader with timeout capability
|
|
137
|
+
* Prevents hanging on stalled streams (uses STREAM_READ_TIMEOUT_MS for agent tool use)
|
|
138
|
+
*
|
|
139
|
+
* @param {ReadableStreamDefaultReader} reader - The stream reader
|
|
140
|
+
* @param {number} timeoutMs - Timeout in milliseconds (default from config)
|
|
141
|
+
* @returns {Promise} - Resolves with read result or rejects on timeout
|
|
142
|
+
*/
|
|
143
|
+
function createReadWithTimeout(reader, timeoutMs = STREAM_READ_TIMEOUT_MS) {
|
|
144
|
+
return Promise.race([
|
|
145
|
+
reader.read(),
|
|
146
|
+
new Promise((_, reject) =>
|
|
147
|
+
setTimeout(() => reject(new Error('Stream read timed out - gateway may be stalled')), timeoutMs)
|
|
148
|
+
),
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Estimate token usage when not provided by the API
|
|
154
|
+
*
|
|
155
|
+
* @param {string} inputMessage - The input message
|
|
156
|
+
* @param {string} outputResponse - The output response
|
|
157
|
+
* @returns {Object} - Estimated token usage object
|
|
158
|
+
*/
|
|
159
|
+
function estimateTokenUsage(inputMessage, outputResponse) {
|
|
160
|
+
const promptTokens = Math.ceil(inputMessage.length / 4);
|
|
161
|
+
const completionTokens = Math.ceil(outputResponse.length / 4);
|
|
162
|
+
return {
|
|
163
|
+
prompt_tokens: promptTokens,
|
|
164
|
+
completion_tokens: completionTokens,
|
|
165
|
+
total_tokens: promptTokens + completionTokens,
|
|
166
|
+
estimated: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Process events from the channel webhook stream
|
|
172
|
+
*
|
|
173
|
+
* @param {Object} params
|
|
174
|
+
* @param {AsyncIterable} params.eventStream - The channel event stream
|
|
175
|
+
* @param {AbortSignal} params.signal - Abort signal for cancellation
|
|
176
|
+
* @param {Object} params.callbacks - Event callbacks
|
|
177
|
+
* @returns {Object} - { fullResponse, tokenUsage, detectedTools }
|
|
178
|
+
*/
|
|
179
|
+
async function processChannelStream({ eventStream, signal, callbacks }) {
|
|
180
|
+
const { onChunk, onTool, onThinking } = callbacks;
|
|
181
|
+
let fullResponse = '';
|
|
182
|
+
let tokenUsage = null;
|
|
183
|
+
const detectedTools = [];
|
|
184
|
+
|
|
185
|
+
for await (const event of eventStream) {
|
|
186
|
+
if (signal?.aborted) {
|
|
187
|
+
log('debug', '[Channel] Request aborted during channel streaming');
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
switch (event.type) {
|
|
192
|
+
case 'thinking':
|
|
193
|
+
onThinking?.();
|
|
194
|
+
break;
|
|
195
|
+
case 'chunk':
|
|
196
|
+
if (event.content) {
|
|
197
|
+
fullResponse += event.content;
|
|
198
|
+
onChunk?.(event.content);
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'tool':
|
|
202
|
+
if (event.tool && !detectedTools.includes(event.tool)) {
|
|
203
|
+
detectedTools.push(event.tool);
|
|
204
|
+
onTool?.(event.tool);
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
case 'complete':
|
|
208
|
+
if (event.response) {
|
|
209
|
+
fullResponse = event.response;
|
|
210
|
+
}
|
|
211
|
+
if (event.usage) {
|
|
212
|
+
tokenUsage = event.usage;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
case 'error':
|
|
216
|
+
throw new Error(event.error || 'Unknown channel error');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { fullResponse, tokenUsage, detectedTools };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build the gateway request configuration
|
|
225
|
+
*
|
|
226
|
+
* @param {Object} params
|
|
227
|
+
* @param {string} params.message - The message to send
|
|
228
|
+
* @param {string} params.satelliteId - Satellite ID
|
|
229
|
+
* @param {string} params.satelliteName - Satellite display name (for session label)
|
|
230
|
+
* @param {string} params.mode - 'text' or 'voice'
|
|
231
|
+
* @param {AbortSignal} params.signal - Abort signal
|
|
232
|
+
* @returns {Object} - { url, options, sessionKey }
|
|
233
|
+
*/
|
|
234
|
+
// Re-use isGatewayCommand from gateway-commands.js (single source of truth - M-32 fix)
|
|
235
|
+
const isGatewayCommand = isGatewayCommandUtil;
|
|
236
|
+
|
|
237
|
+
async function buildGatewayRequest({ message, satelliteId, satelliteName, agentId, mode, signal }) {
|
|
238
|
+
// Get gateway config dynamically (includes auto-discovered values)
|
|
239
|
+
const gw = await getGatewayConfig();
|
|
240
|
+
|
|
241
|
+
// Don't prefix gateway slash commands — gateway needs to see them raw
|
|
242
|
+
const isCommand = isGatewayCommand(message);
|
|
243
|
+
const prefix = isCommand
|
|
244
|
+
? ''
|
|
245
|
+
: mode === 'voice'
|
|
246
|
+
? '[Voice chat - keep response brief and conversational, 1-2 sentences max] '
|
|
247
|
+
: '[Text chat via Uplink] ';
|
|
248
|
+
|
|
249
|
+
// Build canonical session key format (OpenClaw: agent:{agentId}:{provider}:{scope}:{identifier})
|
|
250
|
+
// Main satellite shares session with Dashboard: agent:main:main
|
|
251
|
+
// Other satellites get isolated: agent:{agentId}:uplink:satellite:<satelliteId>
|
|
252
|
+
// agentId determines which agent handles the session
|
|
253
|
+
const agent = agentId || 'main';
|
|
254
|
+
const sessionKey = satelliteId === 'main'
|
|
255
|
+
? `agent:${agent}:main` // Share with Dashboard for session sync
|
|
256
|
+
: `agent:${agent}:uplink:satellite:${satelliteId}`;
|
|
257
|
+
|
|
258
|
+
if (!gw.url) {
|
|
259
|
+
throw new Error('Gateway URL not configured. Check OpenClaw gateway settings.');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
log('debug', `[Channel] Direct gateway call: sessionKey=${sessionKey}, mode=${mode}, label=${satelliteName}`);
|
|
263
|
+
log('debug', `[Channel] Using gateway: ${gw.url}, hasToken: ${!!gw.token}`);
|
|
264
|
+
|
|
265
|
+
const url = `${gw.url}/v1/chat/completions`;
|
|
266
|
+
const headers = {
|
|
267
|
+
'Content-Type': 'application/json',
|
|
268
|
+
'Authorization': `Bearer ${gw.token}`,
|
|
269
|
+
'x-openclaw-session-key': sessionKey,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Add session label if satellite name provided
|
|
273
|
+
if (satelliteName) {
|
|
274
|
+
headers['x-openclaw-session-label'] = satelliteName;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const options = {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers,
|
|
280
|
+
body: JSON.stringify({
|
|
281
|
+
model: 'openclaw',
|
|
282
|
+
user: `uplink-default`,
|
|
283
|
+
stream: true,
|
|
284
|
+
stream_options: { include_usage: true },
|
|
285
|
+
messages: [{ role: 'user', content: `${prefix}${message}` }],
|
|
286
|
+
}),
|
|
287
|
+
signal,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return { url, options, sessionKey };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Validate gateway response and return the reader
|
|
295
|
+
*
|
|
296
|
+
* @param {Response} response - The fetch response
|
|
297
|
+
* @returns {ReadableStreamDefaultReader} - The body reader
|
|
298
|
+
* @throws {Error} - If response is not ok or has no body
|
|
299
|
+
*/
|
|
300
|
+
async function validateGatewayResponse(response) {
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
const text = await response.text();
|
|
303
|
+
throw new Error(`Gateway error: ${response.status} - ${text}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
log('debug', `[Channel] Gateway response received: status=${response.status}, hasBody=${!!response.body}, bodyType=${response.body?.constructor?.name}`);
|
|
307
|
+
|
|
308
|
+
if (!response.body) {
|
|
309
|
+
throw new Error('Response has no body');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return response.body.getReader();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Parse SSE line and extract content/metadata
|
|
317
|
+
*
|
|
318
|
+
* @param {string} line - The SSE line to parse
|
|
319
|
+
* @returns {Object|null} - Parsed data or null if not parseable
|
|
320
|
+
*/
|
|
321
|
+
function parseSSELine(line) {
|
|
322
|
+
if (!line.startsWith('data: ')) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const data = line.slice(6);
|
|
327
|
+
if (data === '[DONE]') {
|
|
328
|
+
log('debug', '[Channel] Got [DONE] marker');
|
|
329
|
+
return { done: true };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const parsed = JSON.parse(data);
|
|
334
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
335
|
+
|
|
336
|
+
// Extract tool call names from OpenAI-compatible streaming format
|
|
337
|
+
const toolCalls = delta?.tool_calls;
|
|
338
|
+
let toolName = null;
|
|
339
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
340
|
+
// tool_calls[].function.name appears in the first chunk for each tool call
|
|
341
|
+
toolName = toolCalls[0]?.function?.name || null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
content: delta?.content || '',
|
|
346
|
+
usage: parsed.usage || null,
|
|
347
|
+
toolName,
|
|
348
|
+
};
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Process the gateway SSE stream
|
|
356
|
+
*
|
|
357
|
+
* @param {Object} params
|
|
358
|
+
* @param {ReadableStreamDefaultReader} params.reader - Stream reader
|
|
359
|
+
* @param {AbortSignal} params.signal - Abort signal
|
|
360
|
+
* @param {Object} params.callbacks - Event callbacks
|
|
361
|
+
* @returns {Object} - { fullResponse, tokenUsage, detectedTools }
|
|
362
|
+
*/
|
|
363
|
+
async function processGatewayStream({ reader, signal, callbacks }) {
|
|
364
|
+
const { onChunk, onTool, onThinking } = callbacks;
|
|
365
|
+
const decoder = new TextDecoder();
|
|
366
|
+
|
|
367
|
+
let buffer = '';
|
|
368
|
+
let fullResponse = '';
|
|
369
|
+
let tokenUsage = null;
|
|
370
|
+
const detectedTools = [];
|
|
371
|
+
let chunkCount = 0;
|
|
372
|
+
|
|
373
|
+
onThinking?.();
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
while (true) {
|
|
377
|
+
if (signal?.aborted) {
|
|
378
|
+
log('debug', '[Channel] Request aborted, stopping stream read');
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
log('debug', `[Channel] About to read chunk ${chunkCount + 1}...`);
|
|
383
|
+
const { done, value } = await createReadWithTimeout(reader);
|
|
384
|
+
log('debug', `[Channel] Read returned: done=${done}, valueLen=${value?.length || 0}`);
|
|
385
|
+
|
|
386
|
+
if (done) {
|
|
387
|
+
log('debug', `[Channel] Stream complete after ${chunkCount} chunks, response: ${fullResponse.length} chars`);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
chunkCount++;
|
|
392
|
+
const rawChunk = decoder.decode(value, { stream: true });
|
|
393
|
+
buffer += rawChunk;
|
|
394
|
+
|
|
395
|
+
if (chunkCount <= 3) {
|
|
396
|
+
log('debug', `[Channel] Chunk ${chunkCount}: ${rawChunk.substring(0, 200)}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const lines = buffer.split('\n');
|
|
400
|
+
buffer = lines.pop() || '';
|
|
401
|
+
|
|
402
|
+
for (const line of lines) {
|
|
403
|
+
const parsed = parseSSELine(line);
|
|
404
|
+
if (!parsed || parsed.done) continue;
|
|
405
|
+
|
|
406
|
+
if (parsed.usage) {
|
|
407
|
+
tokenUsage = parsed.usage;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Detect tool usage from structured tool_calls field
|
|
411
|
+
if (parsed.toolName) {
|
|
412
|
+
log('debug', `[Channel] Tool detected from stream: ${parsed.toolName}`);
|
|
413
|
+
}
|
|
414
|
+
if (parsed.toolName && !detectedTools.includes(parsed.toolName)) {
|
|
415
|
+
detectedTools.push(parsed.toolName);
|
|
416
|
+
onTool?.(parsed.toolName);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (parsed.content) {
|
|
420
|
+
fullResponse += parsed.content;
|
|
421
|
+
onChunk?.(parsed.content);
|
|
422
|
+
|
|
423
|
+
// Fallback: detect tool usage in content via XML pattern
|
|
424
|
+
const toolMatch = parsed.content.match(/<(?:antml:)?invoke name="(\w+)"/);
|
|
425
|
+
if (toolMatch && !detectedTools.includes(toolMatch[1])) {
|
|
426
|
+
detectedTools.push(toolMatch[1]);
|
|
427
|
+
onTool?.(toolMatch[1]);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} finally {
|
|
433
|
+
reader.releaseLock();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { fullResponse, tokenUsage, detectedTools };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ============================================================================
|
|
440
|
+
// Main sendMessage Function
|
|
441
|
+
// ============================================================================
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Send a message - routes through channel if enabled, otherwise direct to gateway
|
|
445
|
+
* Returns the full response and optional usage stats
|
|
446
|
+
*
|
|
447
|
+
* @param {Object} params
|
|
448
|
+
* @param {string} params.message - The message to send
|
|
449
|
+
* @param {string} params.satelliteId - Satellite ID
|
|
450
|
+
* @param {string} params.satelliteName - Satellite display name (for session label)
|
|
451
|
+
* @param {string} params.mode - 'text' or 'voice'
|
|
452
|
+
* @param {AbortSignal} params.signal - Optional abort signal for cancellation
|
|
453
|
+
* @param {function} params.onChunk - Callback for stream chunks
|
|
454
|
+
* @param {function} params.onTool - Callback for tool usage
|
|
455
|
+
* @param {function} params.onThinking - Callback for thinking indicator
|
|
456
|
+
*/
|
|
457
|
+
export async function sendMessage({
|
|
458
|
+
message,
|
|
459
|
+
satelliteId = 'main',
|
|
460
|
+
satelliteName,
|
|
461
|
+
agentId,
|
|
462
|
+
mode = 'text',
|
|
463
|
+
requestId,
|
|
464
|
+
signal,
|
|
465
|
+
onChunk,
|
|
466
|
+
onTool,
|
|
467
|
+
onThinking,
|
|
468
|
+
}) {
|
|
469
|
+
const callbacks = { onChunk, onTool, onThinking };
|
|
470
|
+
let result;
|
|
471
|
+
|
|
472
|
+
if (isChannelEnabled()) {
|
|
473
|
+
// Route through channel webhook
|
|
474
|
+
const eventStream = sendViaChannel({ message, satelliteId, mode, requestId });
|
|
475
|
+
result = await processChannelStream({ eventStream, signal, callbacks });
|
|
476
|
+
} else {
|
|
477
|
+
// Direct gateway call
|
|
478
|
+
const { url, options } = await buildGatewayRequest({ message, satelliteId, satelliteName, agentId, mode, signal });
|
|
479
|
+
const response = await fetch(url, options);
|
|
480
|
+
const reader = await validateGatewayResponse(response);
|
|
481
|
+
result = await processGatewayStream({ reader, signal, callbacks });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const { fullResponse, tokenUsage, detectedTools } = result;
|
|
485
|
+
|
|
486
|
+
// Parse MEDIA: references from agent responses
|
|
487
|
+
const mediaPattern = /MEDIA:(.+?)(?:\n|$)/g;
|
|
488
|
+
const mediaRefs = [];
|
|
489
|
+
let match;
|
|
490
|
+
while ((match = mediaPattern.exec(fullResponse)) !== null) {
|
|
491
|
+
mediaRefs.push(match[1].trim());
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Strip MEDIA: lines from displayed text
|
|
495
|
+
let cleanResponse = mediaRefs.length > 0
|
|
496
|
+
? fullResponse.replace(/MEDIA:.+?(?:\n|$)/g, '').trim()
|
|
497
|
+
: fullResponse;
|
|
498
|
+
|
|
499
|
+
// Rewrite local file paths in markdown images to proxy URLs (if registerAgentMedia is available)
|
|
500
|
+
// This handles cases where agents embed  in markdown
|
|
501
|
+
const registerFn = global.registerAgentMedia || globalThis.registerAgentMedia;
|
|
502
|
+
if (registerFn && typeof registerFn === 'function') {
|
|
503
|
+
cleanResponse = cleanResponse.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
|
|
504
|
+
const trimmedUrl = url.trim();
|
|
505
|
+
// Skip URLs that are already http(s) or proxy URLs
|
|
506
|
+
if (/^(https?:|\/api\/)/i.test(trimmedUrl)) {
|
|
507
|
+
return match;
|
|
508
|
+
}
|
|
509
|
+
// Try to register the local path as agent media
|
|
510
|
+
const proxyUrl = registerFn(trimmedUrl);
|
|
511
|
+
if (proxyUrl) {
|
|
512
|
+
log('debug', `[Channel] Rewrote markdown image: ${trimmedUrl} -> ${proxyUrl}`);
|
|
513
|
+
return ``;
|
|
514
|
+
}
|
|
515
|
+
// If registration failed, leave as-is (client will handle gracefully)
|
|
516
|
+
return match;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Use estimated tokens if not provided by API
|
|
521
|
+
const finalUsage = tokenUsage || estimateTokenUsage(message, cleanResponse);
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
response: cleanResponse,
|
|
525
|
+
usage: finalUsage,
|
|
526
|
+
tools: detectedTools.length > 0 ? detectedTools : undefined,
|
|
527
|
+
media: mediaRefs.length > 0 ? mediaRefs : undefined,
|
|
528
|
+
};
|
|
529
|
+
}
|