@taptapai/taptapai-openclaw 0.0.2 → 0.1.1
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.
- package/package.json +1 -1
- package/src/backendWs.ts +22 -3
- package/src/deviceAuth.ts +151 -0
- package/src/gatewayWs.ts +52 -7
- package/src/plugin.ts +19 -9
- package/src/requestHandler.ts +11 -10
package/package.json
CHANGED
package/src/backendWs.ts
CHANGED
|
@@ -30,6 +30,10 @@ export type BackendWsState = {
|
|
|
30
30
|
reconnectAttempt: number;
|
|
31
31
|
watchdogTimer: ReturnType<typeof setInterval> | null;
|
|
32
32
|
pingTimer: ReturnType<typeof setInterval> | null;
|
|
33
|
+
/** Tracks consecutive connect errors for log throttling. */
|
|
34
|
+
_consecutiveConnectErrors: number;
|
|
35
|
+
/** Timestamp of last logged connect error. */
|
|
36
|
+
_lastConnectErrorLog: number;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
export function connectBackendWs(params: {
|
|
@@ -92,6 +96,7 @@ export function connectBackendWs(params: {
|
|
|
92
96
|
try {
|
|
93
97
|
logger.info?.("[taptapai] WS open, sending handshake...");
|
|
94
98
|
state.reconnectAttempt = 0;
|
|
99
|
+
state._consecutiveConnectErrors = 0;
|
|
95
100
|
ws.send(
|
|
96
101
|
JSON.stringify({
|
|
97
102
|
type: "handshake",
|
|
@@ -151,11 +156,25 @@ export function connectBackendWs(params: {
|
|
|
151
156
|
ws.addEventListener("error", (event: any) => {
|
|
152
157
|
if (state.ws !== ws) return;
|
|
153
158
|
const msg = event?.message || event?.error?.message || String(event);
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
|
|
160
|
+
// Throttle identical connection errors to avoid log flood during startup.
|
|
161
|
+
state._consecutiveConnectErrors++;
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
const sinceLast = now - state._lastConnectErrorLog;
|
|
164
|
+
if (state._consecutiveConnectErrors === 1 || sinceLast >= 5000) {
|
|
165
|
+
const suffix = state._consecutiveConnectErrors > 1
|
|
166
|
+
? ` (repeated ${state._consecutiveConnectErrors} times)`
|
|
167
|
+
: "";
|
|
168
|
+
logger.warn?.(`[taptapai] WS error: ${msg}${suffix}`);
|
|
169
|
+
state._lastConnectErrorLog = now;
|
|
170
|
+
state._consecutiveConnectErrors = 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Null out state.ws BEFORE closing to prevent synchronous close handler re-entry.
|
|
156
174
|
state.ws = null;
|
|
157
175
|
state.connected = false;
|
|
158
176
|
state.connecting = false;
|
|
177
|
+
try { ws.close(); } catch {}
|
|
159
178
|
scheduleReconnect({ ...params, url: normalizedUrl, token: normalizedToken });
|
|
160
179
|
});
|
|
161
180
|
|
|
@@ -199,7 +218,7 @@ export function scheduleReconnect(params: {
|
|
|
199
218
|
if (state.connecting) return;
|
|
200
219
|
|
|
201
220
|
state.reconnectAttempt++;
|
|
202
|
-
const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempt - 1), MAX_RECONNECT_DELAY_MS);
|
|
221
|
+
const delay = Math.min(Math.max(2000, 1000 * Math.pow(2, state.reconnectAttempt - 1)), MAX_RECONNECT_DELAY_MS);
|
|
203
222
|
logger.info?.(`[taptapai] Reconnecting in ${delay}ms (attempt ${state.reconnectAttempt})...`);
|
|
204
223
|
state.reconnectTimer = setTimeout(() => {
|
|
205
224
|
state.reconnectTimer = null;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device authentication for the local OpenClaw gateway.
|
|
3
|
+
*
|
|
4
|
+
* The gateway strips all scopes from WebSocket connections that do not include
|
|
5
|
+
* a valid device identity. Without scopes, methods like `chat.send` (which
|
|
6
|
+
* requires `operator.write`) are rejected.
|
|
7
|
+
*
|
|
8
|
+
* This module reads the device keypair that the `openclaw` CLI created during
|
|
9
|
+
* its initial pairing (`~/.openclaw/identity/device.json`) and builds the
|
|
10
|
+
* cryptographic `device` object that must be included in the WS `connect`
|
|
11
|
+
* frame.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as crypto from "crypto";
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import * as os from "os";
|
|
18
|
+
import type { LoggerLike } from "./types";
|
|
19
|
+
|
|
20
|
+
// ── Types ──
|
|
21
|
+
|
|
22
|
+
export interface DeviceIdentity {
|
|
23
|
+
deviceId: string;
|
|
24
|
+
publicKeyPem: string;
|
|
25
|
+
privateKeyPem: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeviceConnectField {
|
|
29
|
+
id: string;
|
|
30
|
+
publicKey: string; // base64url raw 32-byte Ed25519 public key
|
|
31
|
+
signature: string; // base64url Ed25519 signature of the auth payload
|
|
32
|
+
signedAt: number; // ms since epoch
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Helpers ──
|
|
36
|
+
|
|
37
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
38
|
+
|
|
39
|
+
/** Convert standard base64 to base64url (no padding). */
|
|
40
|
+
function toBase64Url(buf: Buffer): string {
|
|
41
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract the raw 32-byte public key from a PEM-encoded Ed25519 public key. */
|
|
45
|
+
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
46
|
+
const keyObj = crypto.createPublicKey(publicKeyPem);
|
|
47
|
+
const spkiDer = keyObj.export({ type: "spki", format: "der" });
|
|
48
|
+
// Ed25519 SPKI = 12-byte prefix + 32-byte raw key
|
|
49
|
+
return spkiDer.subarray(ED25519_SPKI_PREFIX.length);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build the same payload string that the gateway expects. */
|
|
53
|
+
function buildDeviceAuthPayload(params: {
|
|
54
|
+
deviceId: string;
|
|
55
|
+
clientId: string;
|
|
56
|
+
clientMode: string;
|
|
57
|
+
role: string;
|
|
58
|
+
scopes: string[];
|
|
59
|
+
signedAtMs: number;
|
|
60
|
+
token: string | null;
|
|
61
|
+
}): string {
|
|
62
|
+
// v1 format (no nonce): version|deviceId|clientId|clientMode|role|scopes|signedAtMs|token
|
|
63
|
+
return [
|
|
64
|
+
"v1",
|
|
65
|
+
params.deviceId,
|
|
66
|
+
params.clientId,
|
|
67
|
+
params.clientMode,
|
|
68
|
+
params.role,
|
|
69
|
+
params.scopes.join(","),
|
|
70
|
+
String(params.signedAtMs),
|
|
71
|
+
params.token ?? "",
|
|
72
|
+
].join("|");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sign a payload with an Ed25519 private key and return base64url signature. */
|
|
76
|
+
function signPayload(privateKeyPem: string, payload: string): string {
|
|
77
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
78
|
+
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
|
|
79
|
+
return toBase64Url(sig);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Public API ──
|
|
83
|
+
|
|
84
|
+
const DEFAULT_IDENTITY_PATH = path.join(os.homedir(), ".openclaw", "identity", "device.json");
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load the device identity from disk.
|
|
88
|
+
* Returns `null` if the file does not exist or is malformed.
|
|
89
|
+
*/
|
|
90
|
+
export function loadDeviceIdentity(logger: LoggerLike, filePath?: string): DeviceIdentity | null {
|
|
91
|
+
const p = filePath ?? DEFAULT_IDENTITY_PATH;
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.existsSync(p)) {
|
|
94
|
+
logger.info?.(`[taptapai] No device identity at ${p} — device auth disabled`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
98
|
+
if (raw?.version === 1 && raw.deviceId && raw.publicKeyPem && raw.privateKeyPem) {
|
|
99
|
+
logger.info?.(`[taptapai] Loaded device identity: ${raw.deviceId.substring(0, 12)}...`);
|
|
100
|
+
return {
|
|
101
|
+
deviceId: raw.deviceId,
|
|
102
|
+
publicKeyPem: raw.publicKeyPem,
|
|
103
|
+
privateKeyPem: raw.privateKeyPem,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
logger.warn?.(`[taptapai] Device identity at ${p} has unexpected format`);
|
|
107
|
+
return null;
|
|
108
|
+
} catch (e: any) {
|
|
109
|
+
logger.warn?.(`[taptapai] Failed to load device identity: ${e?.message}`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the `device` field for the gateway WS `connect` frame.
|
|
116
|
+
*
|
|
117
|
+
* @param identity The device identity (from `loadDeviceIdentity`).
|
|
118
|
+
* @param clientId The client ID used in the connect frame (e.g. "gateway-client").
|
|
119
|
+
* @param clientMode The client mode (e.g. "backend").
|
|
120
|
+
* @param role The role (e.g. "operator").
|
|
121
|
+
* @param scopes The scopes to request (e.g. ["operator.admin", "operator.write", "operator.read"]).
|
|
122
|
+
* @param authToken The gateway auth token sent in `auth.token`.
|
|
123
|
+
*/
|
|
124
|
+
export function buildDeviceConnect(
|
|
125
|
+
identity: DeviceIdentity,
|
|
126
|
+
clientId: string,
|
|
127
|
+
clientMode: string,
|
|
128
|
+
role: string,
|
|
129
|
+
scopes: string[],
|
|
130
|
+
authToken: string,
|
|
131
|
+
): DeviceConnectField {
|
|
132
|
+
const signedAtMs = Date.now();
|
|
133
|
+
const payload = buildDeviceAuthPayload({
|
|
134
|
+
deviceId: identity.deviceId,
|
|
135
|
+
clientId,
|
|
136
|
+
clientMode,
|
|
137
|
+
role,
|
|
138
|
+
scopes,
|
|
139
|
+
signedAtMs,
|
|
140
|
+
token: authToken,
|
|
141
|
+
});
|
|
142
|
+
const signature = signPayload(identity.privateKeyPem, payload);
|
|
143
|
+
const publicKey = toBase64Url(derivePublicKeyRaw(identity.publicKeyPem));
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
id: identity.deviceId,
|
|
147
|
+
publicKey,
|
|
148
|
+
signature,
|
|
149
|
+
signedAt: signedAtMs,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/gatewayWs.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
|
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { dirname, join } from "path";
|
|
6
6
|
import type { LoggerLike } from "./types";
|
|
7
|
+
import { loadDeviceIdentity, buildDeviceConnect, type DeviceIdentity } from "./deviceAuth";
|
|
7
8
|
|
|
8
9
|
let _pluginVersion = "";
|
|
9
10
|
function getPluginVersion(): string {
|
|
@@ -42,6 +43,12 @@ export type GatewayWsState = {
|
|
|
42
43
|
rpcId: number;
|
|
43
44
|
pendingRequests: Map<string, PendingRequest>;
|
|
44
45
|
pendingChat: Map<string, PendingChat>;
|
|
46
|
+
/** Tracks consecutive connect errors for log throttling. */
|
|
47
|
+
_consecutiveConnectErrors: number;
|
|
48
|
+
/** Timestamp of last logged connect error. */
|
|
49
|
+
_lastConnectErrorLog: number;
|
|
50
|
+
/** Cached device identity for device-auth (loaded once). */
|
|
51
|
+
_deviceIdentity?: DeviceIdentity | null;
|
|
45
52
|
};
|
|
46
53
|
|
|
47
54
|
export function getGatewayToken(runtime: any, pluginConfig: any, readOpenClawConfig: () => any): string {
|
|
@@ -111,6 +118,7 @@ export function connectGatewayWs(params: {
|
|
|
111
118
|
try { clearTimeout(connectTimeout); } catch {}
|
|
112
119
|
logger.info?.("[taptapai] Gateway WS open, waiting for connect.challenge...");
|
|
113
120
|
state.reconnectAttempt = 0;
|
|
121
|
+
state._consecutiveConnectErrors = 0;
|
|
114
122
|
});
|
|
115
123
|
|
|
116
124
|
ws.addEventListener("message", (event: MessageEvent) => {
|
|
@@ -171,12 +179,25 @@ export function connectGatewayWs(params: {
|
|
|
171
179
|
if (state.ws !== ws) return;
|
|
172
180
|
const anyEvent: any = event as any;
|
|
173
181
|
const msg = anyEvent?.message || anyEvent?.error?.message || String(event);
|
|
174
|
-
|
|
182
|
+
|
|
183
|
+
// Throttle identical connection errors to avoid log flood during startup.
|
|
184
|
+
state._consecutiveConnectErrors++;
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const sinceLast = now - state._lastConnectErrorLog;
|
|
187
|
+
if (state._consecutiveConnectErrors === 1 || sinceLast >= 5000) {
|
|
188
|
+
const suffix = state._consecutiveConnectErrors > 1
|
|
189
|
+
? ` (repeated ${state._consecutiveConnectErrors} times)`
|
|
190
|
+
: "";
|
|
191
|
+
logger.warn?.(`[taptapai] Gateway WS error: ${msg}${suffix}`);
|
|
192
|
+
state._lastConnectErrorLog = now;
|
|
193
|
+
state._consecutiveConnectErrors = 0;
|
|
194
|
+
}
|
|
175
195
|
|
|
176
196
|
try { clearTimeout(connectTimeout); } catch {}
|
|
177
|
-
|
|
197
|
+
// Null out state.ws BEFORE closing to prevent synchronous close handler re-entry.
|
|
178
198
|
state.connected = false;
|
|
179
199
|
state.ws = null;
|
|
200
|
+
try { ws.close(); } catch {}
|
|
180
201
|
|
|
181
202
|
// Some implementations emit error without close; make sure we fail any waiters.
|
|
182
203
|
for (const [id, pending] of state.pendingRequests) {
|
|
@@ -209,6 +230,29 @@ function sendGatewayConnect(params: {
|
|
|
209
230
|
}
|
|
210
231
|
|
|
211
232
|
const id = `gw-connect-${++state.rpcId}`;
|
|
233
|
+
|
|
234
|
+
// Load device identity once (cached on the state object).
|
|
235
|
+
// The device keypair was created by the `openclaw` CLI during initial pairing.
|
|
236
|
+
// Including it in the connect frame prevents the gateway from stripping scopes.
|
|
237
|
+
if (state._deviceIdentity === undefined) {
|
|
238
|
+
state._deviceIdentity = loadDeviceIdentity(logger);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const clientId = "gateway-client";
|
|
242
|
+
const clientMode = "backend";
|
|
243
|
+
const role = "operator";
|
|
244
|
+
const scopes = ["operator.admin", "operator.write", "operator.read"];
|
|
245
|
+
|
|
246
|
+
const device = state._deviceIdentity
|
|
247
|
+
? buildDeviceConnect(state._deviceIdentity, clientId, clientMode, role, scopes, token)
|
|
248
|
+
: undefined;
|
|
249
|
+
|
|
250
|
+
if (device) {
|
|
251
|
+
logger.info?.(`[taptapai] Including device auth in connect (device=${device.id.substring(0, 12)}...)`);
|
|
252
|
+
} else {
|
|
253
|
+
logger.warn?.(`[taptapai] No device identity — scopes will be stripped by gateway`);
|
|
254
|
+
}
|
|
255
|
+
|
|
212
256
|
const frame = {
|
|
213
257
|
type: "req",
|
|
214
258
|
id,
|
|
@@ -217,15 +261,16 @@ function sendGatewayConnect(params: {
|
|
|
217
261
|
minProtocol: GATEWAY_PROTOCOL_VERSION,
|
|
218
262
|
maxProtocol: GATEWAY_PROTOCOL_VERSION,
|
|
219
263
|
client: {
|
|
220
|
-
id:
|
|
264
|
+
id: clientId,
|
|
221
265
|
displayName: "TapTapAI Plugin",
|
|
222
266
|
version: getPluginVersion(),
|
|
223
267
|
platform: process.platform || "linux",
|
|
224
|
-
mode:
|
|
268
|
+
mode: clientMode,
|
|
225
269
|
},
|
|
226
270
|
auth: { token },
|
|
227
|
-
role
|
|
228
|
-
scopes
|
|
271
|
+
role,
|
|
272
|
+
scopes,
|
|
273
|
+
device,
|
|
229
274
|
},
|
|
230
275
|
};
|
|
231
276
|
|
|
@@ -265,7 +310,7 @@ function scheduleGatewayReconnect(params: {
|
|
|
265
310
|
if (state.reconnectTimer) return;
|
|
266
311
|
|
|
267
312
|
state.reconnectAttempt++;
|
|
268
|
-
const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempt - 1), MAX_RECONNECT_DELAY_MS);
|
|
313
|
+
const delay = Math.min(Math.max(2000, 1000 * Math.pow(2, state.reconnectAttempt - 1)), MAX_RECONNECT_DELAY_MS);
|
|
269
314
|
logger.info?.(`[taptapai] Gateway WS reconnecting in ${delay}ms (attempt ${state.reconnectAttempt})...`);
|
|
270
315
|
state.reconnectTimer = setTimeout(() => {
|
|
271
316
|
state.reconnectTimer = null;
|
package/src/plugin.ts
CHANGED
|
@@ -45,6 +45,8 @@ function createInitialState(): PluginState {
|
|
|
45
45
|
reconnectAttempt: 0,
|
|
46
46
|
watchdogTimer: null,
|
|
47
47
|
pingTimer: null,
|
|
48
|
+
_consecutiveConnectErrors: 0,
|
|
49
|
+
_lastConnectErrorLog: 0,
|
|
48
50
|
},
|
|
49
51
|
gateway: {
|
|
50
52
|
ws: null,
|
|
@@ -55,6 +57,8 @@ function createInitialState(): PluginState {
|
|
|
55
57
|
rpcId: 0,
|
|
56
58
|
pendingRequests: new Map(),
|
|
57
59
|
pendingChat: new Map(),
|
|
60
|
+
_consecutiveConnectErrors: 0,
|
|
61
|
+
_lastConnectErrorLog: 0,
|
|
58
62
|
},
|
|
59
63
|
shutdownHandlersInstalled: false,
|
|
60
64
|
globalErrorGuardsInstalled: false,
|
|
@@ -283,15 +287,21 @@ export function createTapTapAiPlugin() {
|
|
|
283
287
|
state.aborted = false;
|
|
284
288
|
|
|
285
289
|
logger.info?.("[taptapai] Starting local gateway WS client for agent dispatch...");
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
// Delay initial connection to let the gateway WS server finish starting.
|
|
291
|
+
// Inside the gateway process the server may not be listening yet.
|
|
292
|
+
const gwConnectDelay = isRunningInGatewayProcess() ? 3000 : 500;
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
if (aborted()) return;
|
|
295
|
+
connectGatewayWs({
|
|
296
|
+
state: state.gateway,
|
|
297
|
+
logger,
|
|
298
|
+
aborted,
|
|
299
|
+
enabled: () => state.bridgeLock.held,
|
|
300
|
+
readOpenClawConfig: () => readOpenClawConfig(logger),
|
|
301
|
+
runtime,
|
|
302
|
+
pluginConfig,
|
|
303
|
+
});
|
|
304
|
+
}, gwConnectDelay);
|
|
295
305
|
|
|
296
306
|
if (backendWsUrl) {
|
|
297
307
|
logger.info?.(`[taptapai] Starting backend WS connection to ${backendWsUrl}`);
|
package/src/requestHandler.ts
CHANGED
|
@@ -81,15 +81,16 @@ export async function handleBackendRequest(deps: RequestHandlerDeps, req: RpcReq
|
|
|
81
81
|
send("ack");
|
|
82
82
|
|
|
83
83
|
try {
|
|
84
|
+
// Prefer WS chat.send through the local gateway — this requires
|
|
85
|
+
// device auth (operator.write scope). Falls back to HTTP
|
|
86
|
+
// /v1/chat/completions if the WS is not connected.
|
|
87
|
+
let responseText: string;
|
|
84
88
|
if (gatewayState.connected) {
|
|
85
89
|
logger.info?.(`[taptapai] Dispatching via gateway WS (chat.send) [ack sent in ${Date.now() - t0}ms]`);
|
|
86
|
-
|
|
87
|
-
const t1 = Date.now();
|
|
88
|
-
logger.info?.(`[taptapai] ⏱️ agent.send DONE: total=${t1 - t0}ms | Gateway WS response: "${responseText.substring(0, 80)}..."`);
|
|
89
|
-
send("stream", { text: responseText }, { event: "done" });
|
|
90
|
+
responseText = await dispatchViaGateway({ state: gatewayState, logger, text: String(text), session: String(session) });
|
|
90
91
|
} else {
|
|
91
|
-
logger.info?.(
|
|
92
|
-
|
|
92
|
+
logger.info?.(`[taptapai] Gateway WS not connected — falling back to HTTP (chat/completions) [ack sent in ${Date.now() - t0}ms]`);
|
|
93
|
+
responseText = await callGatewayHttp({
|
|
93
94
|
text: String(text),
|
|
94
95
|
session: String(session),
|
|
95
96
|
runtime,
|
|
@@ -97,13 +98,13 @@ export async function handleBackendRequest(deps: RequestHandlerDeps, req: RpcReq
|
|
|
97
98
|
readOpenClawConfig: () => readOpenClawConfig(logger),
|
|
98
99
|
logger,
|
|
99
100
|
});
|
|
100
|
-
const t1 = Date.now();
|
|
101
|
-
logger.info?.(`[taptapai] ⏱️ agent.send HTTP fallback DONE: total=${t1 - t0}ms`);
|
|
102
|
-
send("stream", { text: responseText }, { event: "done" });
|
|
103
101
|
}
|
|
102
|
+
const t1 = Date.now();
|
|
103
|
+
logger.info?.(`[taptapai] ⏱️ agent.send DONE: total=${t1 - t0}ms | Gateway HTTP response: "${responseText.substring(0, 80)}..."`);
|
|
104
|
+
send("stream", { text: responseText }, { event: "done" });
|
|
104
105
|
} catch (e: any) {
|
|
105
106
|
logger.error?.(`[taptapai] agent.send error after ${Date.now() - t0}ms: ${e?.message || e}`);
|
|
106
|
-
send("
|
|
107
|
+
send("error", undefined, { message: `${e?.message || e}`, code: 502 });
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
break;
|