openclaw-vchat-plugin 0.0.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/bin/openclaw-vchat.js +110 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +509 -0
- package/dist/commands.js.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +51 -0
- package/dist/constants.js.map +1 -0
- package/dist/gateway-client.d.ts +43 -0
- package/dist/gateway-client.d.ts.map +1 -0
- package/dist/gateway-client.js +623 -0
- package/dist/gateway-client.js.map +1 -0
- package/dist/group-manager.d.ts +30 -0
- package/dist/group-manager.d.ts.map +1 -0
- package/dist/group-manager.js +107 -0
- package/dist/group-manager.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +382 -0
- package/dist/index.js.map +1 -0
- package/dist/media-handler.d.ts +31 -0
- package/dist/media-handler.d.ts.map +1 -0
- package/dist/media-handler.js +67 -0
- package/dist/media-handler.js.map +1 -0
- package/dist/message-handler.d.ts +52 -0
- package/dist/message-handler.d.ts.map +1 -0
- package/dist/message-handler.js +291 -0
- package/dist/message-handler.js.map +1 -0
- package/dist/relay-server.d.ts +16 -0
- package/dist/relay-server.d.ts.map +1 -0
- package/dist/relay-server.js +877 -0
- package/dist/relay-server.js.map +1 -0
- package/dist/routes/config.routes.d.ts +12 -0
- package/dist/routes/config.routes.d.ts.map +1 -0
- package/dist/routes/config.routes.js +175 -0
- package/dist/routes/config.routes.js.map +1 -0
- package/dist/services/config.service.d.ts +57 -0
- package/dist/services/config.service.d.ts.map +1 -0
- package/dist/services/config.service.js +361 -0
- package/dist/services/config.service.js.map +1 -0
- package/dist/session-key.d.ts +8 -0
- package/dist/session-key.d.ts.map +1 -0
- package/dist/session-key.js +28 -0
- package/dist/session-key.js.map +1 -0
- package/dist/session-manager.d.ts +32 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +303 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/nginx-proxy.conf +24 -0
- package/package.json +51 -0
- package/src/commands.ts +499 -0
- package/src/constants.ts +49 -0
- package/src/gateway-client.ts +648 -0
- package/src/group-manager.ts +119 -0
- package/src/index.ts +443 -0
- package/src/media-handler.ts +70 -0
- package/src/message-handler.ts +419 -0
- package/src/relay-server.ts +979 -0
- package/src/routes/config.routes.ts +144 -0
- package/src/services/config.service.ts +398 -0
- package/src/session-key.ts +30 -0
- package/src/session-manager.ts +374 -0
- package/src/types.ts +96 -0
- package/start.sh +5 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const PROTOCOL_VERSION = 3;
|
|
7
|
+
const DEFAULT_GATEWAY_URL = 'ws://127.0.0.1:18789';
|
|
8
|
+
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || '/root', '.openclaw');
|
|
9
|
+
const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || path.join(OPENCLAW_HOME, 'openclaw.json');
|
|
10
|
+
|
|
11
|
+
function normalizeGatewayUrl(url?: string): string {
|
|
12
|
+
const raw = (url || '').trim();
|
|
13
|
+
if (!raw) return DEFAULT_GATEWAY_URL;
|
|
14
|
+
if (raw.startsWith('ws://') || raw.startsWith('wss://')) return raw;
|
|
15
|
+
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
16
|
+
return raw.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
|
|
17
|
+
}
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readGatewayTokenFromConfig(): string {
|
|
22
|
+
try {
|
|
23
|
+
const cfg = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf-8'));
|
|
24
|
+
return cfg?.gateway?.auth?.token || '';
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getGatewayMessageRole(message: any): string {
|
|
31
|
+
if (!message || typeof message !== 'object') return '';
|
|
32
|
+
return String(message.role || message.author || message.sender || '')
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRenderableGatewayMessage(message: any): boolean {
|
|
38
|
+
const role = getGatewayMessageRole(message);
|
|
39
|
+
if (!role) return true;
|
|
40
|
+
return role === 'assistant' || role === 'system';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractTextFromChatMessage(message: any): string {
|
|
44
|
+
if (!message || typeof message !== 'object') return '';
|
|
45
|
+
if (!isRenderableGatewayMessage(message)) return '';
|
|
46
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
47
|
+
return content
|
|
48
|
+
.map((item: any) => {
|
|
49
|
+
if (typeof item === 'string') return item;
|
|
50
|
+
const type = String(item?.type || '').trim().toLowerCase();
|
|
51
|
+
if (type && type !== 'text') return '';
|
|
52
|
+
return typeof item?.text === 'string' ? item.text : '';
|
|
53
|
+
})
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join('');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GatewayStreamCallbacks {
|
|
59
|
+
onThinking?: (text: string) => void;
|
|
60
|
+
onChunk?: (delta: string) => void;
|
|
61
|
+
onDone?: (fullText: string) => void;
|
|
62
|
+
onError?: (error: string) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface StreamAgentOpts {
|
|
66
|
+
groupId?: string;
|
|
67
|
+
groupChannel?: string;
|
|
68
|
+
attachments?: any[];
|
|
69
|
+
sessionKey?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StreamChatOpts {
|
|
73
|
+
sessionKey: string;
|
|
74
|
+
attachments?: any[];
|
|
75
|
+
thinking?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class GatewayClient {
|
|
79
|
+
private url: string;
|
|
80
|
+
private token: string;
|
|
81
|
+
|
|
82
|
+
constructor(url?: string, token?: string) {
|
|
83
|
+
this.url = normalizeGatewayUrl(url || process.env.OPENCLAW_GATEWAY_URL || DEFAULT_GATEWAY_URL);
|
|
84
|
+
this.token = (token || process.env.OPENCLAW_AUTH_TOKEN || readGatewayTokenFromConfig() || '').trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private buildConnectRequest() {
|
|
88
|
+
const params: any = {
|
|
89
|
+
minProtocol: PROTOCOL_VERSION,
|
|
90
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
91
|
+
role: 'operator',
|
|
92
|
+
scopes: ['operator.admin', 'operator.read', 'operator.write'],
|
|
93
|
+
client: {
|
|
94
|
+
id: 'gateway-client',
|
|
95
|
+
displayName: 'openclaw-wechat-plugin',
|
|
96
|
+
version: '1.0.0',
|
|
97
|
+
platform: process.platform,
|
|
98
|
+
mode: 'backend',
|
|
99
|
+
instanceId: randomUUID(),
|
|
100
|
+
},
|
|
101
|
+
caps: [],
|
|
102
|
+
};
|
|
103
|
+
if (this.token) {
|
|
104
|
+
params.auth = { token: this.token };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
type: 'req',
|
|
108
|
+
id: randomUUID(),
|
|
109
|
+
method: 'connect',
|
|
110
|
+
params,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ========== Gateway WS call() ==========
|
|
115
|
+
|
|
116
|
+
async call(method: string, params: any = {}): Promise<any> {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const ws = new WebSocket(this.url);
|
|
119
|
+
let connected = false;
|
|
120
|
+
const reqId = randomUUID();
|
|
121
|
+
|
|
122
|
+
const timeout = setTimeout(() => {
|
|
123
|
+
console.error(`[GW call] TIMEOUT method=${method} connected=${connected}`);
|
|
124
|
+
ws.close();
|
|
125
|
+
reject(new Error('Gateway 调用超时'));
|
|
126
|
+
}, 15000);
|
|
127
|
+
|
|
128
|
+
ws.on('open', () => console.log(`[GW call] opened for ${method}`));
|
|
129
|
+
|
|
130
|
+
ws.on('message', (data: Buffer) => {
|
|
131
|
+
try {
|
|
132
|
+
const msg = JSON.parse(data.toString());
|
|
133
|
+
|
|
134
|
+
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
|
135
|
+
ws.send(JSON.stringify(this.buildConnectRequest()));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (msg.type === 'res' && !connected) {
|
|
140
|
+
if (msg.ok) {
|
|
141
|
+
connected = true;
|
|
142
|
+
ws.send(JSON.stringify({ type: 'req', id: reqId, method, params }));
|
|
143
|
+
} else {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
ws.close();
|
|
146
|
+
reject(new Error(msg.error?.message || 'Gateway 认证失败'));
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (msg.type === 'res' && msg.id === reqId) {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
ws.close();
|
|
154
|
+
if (msg.ok) resolve(msg.payload);
|
|
155
|
+
else reject(new Error(msg.error?.message || 'Gateway 错误'));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
clearTimeout(timeout);
|
|
160
|
+
ws.close();
|
|
161
|
+
reject(err);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
ws.on('error', (err: Error) => {
|
|
166
|
+
clearTimeout(timeout);
|
|
167
|
+
reject(err);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ========== streamAgent() ==========
|
|
173
|
+
|
|
174
|
+
streamAgent(
|
|
175
|
+
message: string,
|
|
176
|
+
agentId: string = 'main',
|
|
177
|
+
callbacks: GatewayStreamCallbacks,
|
|
178
|
+
opts?: StreamAgentOpts
|
|
179
|
+
): () => void {
|
|
180
|
+
const ws = new WebSocket(this.url);
|
|
181
|
+
const reqId = randomUUID();
|
|
182
|
+
let gwConnected = false;
|
|
183
|
+
let done = false;
|
|
184
|
+
let manualAbort = false;
|
|
185
|
+
let fullText = '';
|
|
186
|
+
let runId = '';
|
|
187
|
+
let sessionKey = (opts?.sessionKey || '').trim().toLowerCase();
|
|
188
|
+
|
|
189
|
+
// 活动超时:10 分钟内无任何消息才超时(每次收到消息重置)
|
|
190
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
191
|
+
const INACTIVITY_MS = 600000;
|
|
192
|
+
|
|
193
|
+
const clearActivityTimer = () => {
|
|
194
|
+
if (timer) {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
timer = undefined;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const resetActivityTimer = () => {
|
|
201
|
+
clearActivityTimer();
|
|
202
|
+
timer = setTimeout(() => {
|
|
203
|
+
if (done) return;
|
|
204
|
+
finishError('AI 回复超时(10分钟无活动)');
|
|
205
|
+
}, INACTIVITY_MS);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const finishDone = (text: string) => {
|
|
209
|
+
if (done) return;
|
|
210
|
+
done = true;
|
|
211
|
+
clearActivityTimer();
|
|
212
|
+
callbacks.onDone?.(text);
|
|
213
|
+
ws.close();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const finishError = (error: string) => {
|
|
217
|
+
if (done) return;
|
|
218
|
+
done = true;
|
|
219
|
+
clearActivityTimer();
|
|
220
|
+
if (!manualAbort) callbacks.onError?.(error);
|
|
221
|
+
ws.close();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const applyDelta = (nextText: string) => {
|
|
225
|
+
const next = nextText || '';
|
|
226
|
+
if (!next) return;
|
|
227
|
+
const delta = next.startsWith(fullText) ? next.slice(fullText.length) : next;
|
|
228
|
+
fullText = next;
|
|
229
|
+
if (delta) callbacks.onChunk?.(delta);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
resetActivityTimer();
|
|
233
|
+
|
|
234
|
+
ws.on('open', () => {
|
|
235
|
+
console.log(`[GW stream] connected (agent=${agentId})`);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
ws.on('message', (data: Buffer) => {
|
|
239
|
+
if (done) return;
|
|
240
|
+
try {
|
|
241
|
+
const msg = JSON.parse(data.toString());
|
|
242
|
+
|
|
243
|
+
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
|
244
|
+
ws.send(JSON.stringify(this.buildConnectRequest()));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (msg.type === 'res' && !gwConnected) {
|
|
249
|
+
if (!msg.ok) {
|
|
250
|
+
finishError(msg.error?.message || 'Gateway 认证失败');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
gwConnected = true;
|
|
254
|
+
callbacks.onThinking?.('正在思考...');
|
|
255
|
+
const agentParams: any = {
|
|
256
|
+
message,
|
|
257
|
+
agentId,
|
|
258
|
+
idempotencyKey: randomUUID(),
|
|
259
|
+
};
|
|
260
|
+
if (sessionKey) agentParams.sessionKey = sessionKey;
|
|
261
|
+
if (opts?.groupId) {
|
|
262
|
+
agentParams.groupId = opts.groupId;
|
|
263
|
+
agentParams.groupChannel = opts.groupChannel || 'wechat';
|
|
264
|
+
}
|
|
265
|
+
if (opts?.attachments && opts.attachments.length > 0) {
|
|
266
|
+
agentParams.attachments = opts.attachments;
|
|
267
|
+
}
|
|
268
|
+
ws.send(JSON.stringify({ type: 'req', id: reqId, method: 'agent', params: agentParams }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (msg.type === 'event' && msg.event === 'agent') {
|
|
273
|
+
resetActivityTimer();
|
|
274
|
+
const payload = msg.payload || {};
|
|
275
|
+
if (payload.runId && !runId) runId = String(payload.runId);
|
|
276
|
+
if (payload.sessionKey) sessionKey = String(payload.sessionKey).toLowerCase();
|
|
277
|
+
if (payload.stream === 'assistant') {
|
|
278
|
+
const text = typeof payload?.data?.text === 'string' ? payload.data.text : '';
|
|
279
|
+
const delta = typeof payload?.data?.delta === 'string' ? payload.data.delta : '';
|
|
280
|
+
if (text) applyDelta(text);
|
|
281
|
+
else if (delta) {
|
|
282
|
+
fullText += delta;
|
|
283
|
+
callbacks.onChunk?.(delta);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (msg.type === 'event' && msg.event === 'chat') {
|
|
290
|
+
resetActivityTimer();
|
|
291
|
+
const payload = msg.payload || {};
|
|
292
|
+
if (payload.runId && runId && payload.runId !== runId) return;
|
|
293
|
+
if (payload.sessionKey) sessionKey = String(payload.sessionKey).toLowerCase();
|
|
294
|
+
const state = payload.state;
|
|
295
|
+
const messageRole = getGatewayMessageRole(payload.message) || '-';
|
|
296
|
+
const renderable = isRenderableGatewayMessage(payload.message);
|
|
297
|
+
const messageText = extractTextFromChatMessage(payload.message);
|
|
298
|
+
|
|
299
|
+
if (!renderable && (state === 'delta' || state === 'final')) {
|
|
300
|
+
console.log(`[GW stream] skip chat message role=${messageRole} state=${state} requestId=${reqId} runId=${runId || payload.runId || '-'} sessionKey=${sessionKey || '-'}`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (state === 'delta') {
|
|
305
|
+
if (messageText) applyDelta(messageText);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (state === 'final') {
|
|
309
|
+
if (messageText) fullText = messageText;
|
|
310
|
+
console.log(`[GW stream] chat.final requestId=${reqId} runId=${runId || payload.runId || '-'} sessionKey=${sessionKey || '-'}`);
|
|
311
|
+
const finalText = fullText.trim();
|
|
312
|
+
if (finalText) finishDone(finalText);
|
|
313
|
+
else finishError('AI 返回了空内容');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (state === 'aborted') {
|
|
317
|
+
console.log(`[GW stream] chat.aborted requestId=${reqId} runId=${runId || payload.runId || '-'} sessionKey=${sessionKey || '-'}`);
|
|
318
|
+
finishError('会话已中断');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (state === 'error') {
|
|
322
|
+
console.log(`[GW stream] chat.error requestId=${reqId} runId=${runId || payload.runId || '-'} sessionKey=${sessionKey || '-'}`);
|
|
323
|
+
finishError(payload.errorMessage || '会话执行失败');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (msg.type === 'res' && msg.id === reqId) {
|
|
329
|
+
if (msg.ok && msg.payload?.status === 'accepted') {
|
|
330
|
+
runId = msg.payload?.runId || runId;
|
|
331
|
+
console.log(`[GW stream] accepted requestId=${reqId} runId=${runId} sessionKey=${sessionKey || '-'}`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (msg.ok && msg.payload?.status === 'ok') {
|
|
335
|
+
if (!fullText && msg.payload?.result?.payloads?.[0]?.text) {
|
|
336
|
+
fullText = msg.payload.result.payloads[0].text;
|
|
337
|
+
}
|
|
338
|
+
console.log(`[GW stream] completed requestId=${reqId} runId=${runId} sessionKey=${sessionKey || '-'}`);
|
|
339
|
+
const finalText = fullText.trim();
|
|
340
|
+
if (finalText) finishDone(finalText);
|
|
341
|
+
else finishError('AI 返回了空内容');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (msg.ok && msg.payload?.status === 'aborted') {
|
|
345
|
+
console.log(`[GW stream] aborted requestId=${reqId} runId=${runId} sessionKey=${sessionKey || '-'}`);
|
|
346
|
+
finishError('会话已中断');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!msg.ok) {
|
|
350
|
+
finishError(msg.error?.message || '未知错误');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
console.error('[GW stream] parse error:', err);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
ws.on('error', (err: Error) => {
|
|
360
|
+
finishError(`Gateway 连接失败: ${err.message}`);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
ws.on('close', () => {
|
|
364
|
+
clearActivityTimer();
|
|
365
|
+
if (!done && !manualAbort) {
|
|
366
|
+
finishError('Gateway 连接已关闭');
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return () => {
|
|
371
|
+
if (done) return;
|
|
372
|
+
manualAbort = true;
|
|
373
|
+
done = true;
|
|
374
|
+
clearActivityTimer();
|
|
375
|
+
try {
|
|
376
|
+
if (gwConnected && sessionKey && ws.readyState === WebSocket.OPEN) {
|
|
377
|
+
const abortParams: any = { sessionKey };
|
|
378
|
+
if (runId) abortParams.runId = runId;
|
|
379
|
+
ws.send(JSON.stringify({
|
|
380
|
+
type: 'req',
|
|
381
|
+
id: randomUUID(),
|
|
382
|
+
method: 'chat.abort',
|
|
383
|
+
params: abortParams,
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
// ignore
|
|
388
|
+
}
|
|
389
|
+
ws.close();
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ========== streamChatSend() ==========
|
|
394
|
+
|
|
395
|
+
streamChatSend(
|
|
396
|
+
message: string,
|
|
397
|
+
callbacks: GatewayStreamCallbacks,
|
|
398
|
+
opts: StreamChatOpts
|
|
399
|
+
): () => void {
|
|
400
|
+
const ws = new WebSocket(this.url);
|
|
401
|
+
const reqId = randomUUID();
|
|
402
|
+
let gwConnected = false;
|
|
403
|
+
let done = false;
|
|
404
|
+
let manualAbort = false;
|
|
405
|
+
let fullText = '';
|
|
406
|
+
let runId = '';
|
|
407
|
+
const sessionKey = String(opts?.sessionKey || '').trim().toLowerCase();
|
|
408
|
+
|
|
409
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
410
|
+
const INACTIVITY_MS = 600000;
|
|
411
|
+
|
|
412
|
+
const clearActivityTimer = () => {
|
|
413
|
+
if (timer) {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
timer = undefined;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const resetActivityTimer = () => {
|
|
420
|
+
clearActivityTimer();
|
|
421
|
+
timer = setTimeout(() => {
|
|
422
|
+
if (done) return;
|
|
423
|
+
finishError('命令执行超时(10分钟无活动)');
|
|
424
|
+
}, INACTIVITY_MS);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const finishDone = (text: string) => {
|
|
428
|
+
if (done) return;
|
|
429
|
+
done = true;
|
|
430
|
+
clearActivityTimer();
|
|
431
|
+
callbacks.onDone?.(text);
|
|
432
|
+
ws.close();
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const finishError = (error: string) => {
|
|
436
|
+
if (done) return;
|
|
437
|
+
done = true;
|
|
438
|
+
clearActivityTimer();
|
|
439
|
+
if (!manualAbort) callbacks.onError?.(error);
|
|
440
|
+
ws.close();
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const applyDelta = (nextText: string) => {
|
|
444
|
+
const next = nextText || '';
|
|
445
|
+
if (!next) return;
|
|
446
|
+
const delta = next.startsWith(fullText) ? next.slice(fullText.length) : next;
|
|
447
|
+
fullText = next;
|
|
448
|
+
if (delta) callbacks.onChunk?.(delta);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
resetActivityTimer();
|
|
452
|
+
|
|
453
|
+
ws.on('open', () => {
|
|
454
|
+
console.log(`[GW chat.send] connected session=${sessionKey || '-'}`);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
ws.on('message', (data: Buffer) => {
|
|
458
|
+
if (done) return;
|
|
459
|
+
try {
|
|
460
|
+
const msg = JSON.parse(data.toString());
|
|
461
|
+
|
|
462
|
+
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
|
463
|
+
ws.send(JSON.stringify(this.buildConnectRequest()));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (msg.type === 'res' && !gwConnected) {
|
|
468
|
+
if (!msg.ok) {
|
|
469
|
+
finishError(msg.error?.message || 'Gateway 认证失败');
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
gwConnected = true;
|
|
473
|
+
callbacks.onThinking?.('正在处理命令...');
|
|
474
|
+
const params: any = {
|
|
475
|
+
sessionKey: sessionKey || 'main',
|
|
476
|
+
message,
|
|
477
|
+
idempotencyKey: randomUUID(),
|
|
478
|
+
deliver: false,
|
|
479
|
+
};
|
|
480
|
+
if (opts?.thinking) params.thinking = opts.thinking;
|
|
481
|
+
if (opts?.attachments && opts.attachments.length > 0) params.attachments = opts.attachments;
|
|
482
|
+
ws.send(JSON.stringify({ type: 'req', id: reqId, method: 'chat.send', params }));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (msg.type === 'res' && msg.id === reqId) {
|
|
487
|
+
if (!msg.ok) {
|
|
488
|
+
finishError(msg.error?.message || '命令执行失败');
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const status = String(msg.payload?.status || '').toLowerCase();
|
|
492
|
+
if (msg.payload?.runId) runId = String(msg.payload.runId);
|
|
493
|
+
console.log(`[GW chat.send] ack status=${status || '-'} runId=${runId || '-'} session=${sessionKey || '-'}`);
|
|
494
|
+
if (status === 'ok' && !runId) {
|
|
495
|
+
const finalText = fullText.trim();
|
|
496
|
+
if (finalText) finishDone(finalText);
|
|
497
|
+
else finishDone('✅ 命令已执行');
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (msg.type === 'event' && msg.event === 'chat') {
|
|
503
|
+
resetActivityTimer();
|
|
504
|
+
const payload = msg.payload || {};
|
|
505
|
+
if (payload.sessionKey) {
|
|
506
|
+
const eventSession = String(payload.sessionKey).toLowerCase();
|
|
507
|
+
if (sessionKey && eventSession !== sessionKey) return;
|
|
508
|
+
}
|
|
509
|
+
if (payload.runId && runId && payload.runId !== runId) return;
|
|
510
|
+
if (!runId && payload.runId) runId = String(payload.runId);
|
|
511
|
+
|
|
512
|
+
const state = payload.state;
|
|
513
|
+
const messageRole = getGatewayMessageRole(payload.message) || '-';
|
|
514
|
+
const renderable = isRenderableGatewayMessage(payload.message);
|
|
515
|
+
const messageText = extractTextFromChatMessage(payload.message);
|
|
516
|
+
|
|
517
|
+
if (!renderable && (state === 'delta' || state === 'final')) {
|
|
518
|
+
console.log(`[GW chat.send] skip chat message role=${messageRole} state=${state} runId=${runId || payload.runId || '-'} session=${sessionKey || '-'}`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (state === 'delta') {
|
|
523
|
+
if (messageText) applyDelta(messageText);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (state === 'final') {
|
|
527
|
+
if (messageText) fullText = messageText;
|
|
528
|
+
const finalText = fullText.trim();
|
|
529
|
+
if (finalText) finishDone(finalText);
|
|
530
|
+
else finishDone('✅ 命令已执行');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (state === 'aborted') {
|
|
534
|
+
finishError('命令执行已中断');
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (state === 'error') {
|
|
538
|
+
finishError(payload.errorMessage || '命令执行失败');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error('[GW chat.send] parse error:', err);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
ws.on('error', (err: Error) => {
|
|
548
|
+
finishError(`Gateway 连接失败: ${err.message}`);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
ws.on('close', () => {
|
|
552
|
+
clearActivityTimer();
|
|
553
|
+
if (!done && !manualAbort) {
|
|
554
|
+
finishError('Gateway 连接已关闭');
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
return () => {
|
|
559
|
+
if (done) return;
|
|
560
|
+
manualAbort = true;
|
|
561
|
+
done = true;
|
|
562
|
+
clearActivityTimer();
|
|
563
|
+
try {
|
|
564
|
+
if (gwConnected && sessionKey && ws.readyState === WebSocket.OPEN) {
|
|
565
|
+
const abortParams: any = { sessionKey };
|
|
566
|
+
if (runId) abortParams.runId = runId;
|
|
567
|
+
ws.send(JSON.stringify({
|
|
568
|
+
type: 'req',
|
|
569
|
+
id: randomUUID(),
|
|
570
|
+
method: 'chat.abort',
|
|
571
|
+
params: abortParams,
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
// ignore
|
|
576
|
+
}
|
|
577
|
+
ws.close();
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ========== 本地配置读写 ==========
|
|
582
|
+
|
|
583
|
+
readConfig(): any {
|
|
584
|
+
try {
|
|
585
|
+
return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf-8'));
|
|
586
|
+
} catch {
|
|
587
|
+
return {};
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async patchConfigLocal(patches: Array<{ path: string; value: any }>): Promise<void> {
|
|
592
|
+
const cfg = this.readConfig();
|
|
593
|
+
for (const { path: p, value } of patches) {
|
|
594
|
+
const parts = p.split('.');
|
|
595
|
+
let target = cfg;
|
|
596
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
597
|
+
if (target[parts[i]] === undefined) target[parts[i]] = {};
|
|
598
|
+
target = target[parts[i]];
|
|
599
|
+
}
|
|
600
|
+
target[parts[parts.length - 1]] = value;
|
|
601
|
+
}
|
|
602
|
+
const tmp = OPENCLAW_CONFIG + '.tmp';
|
|
603
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
604
|
+
fs.renameSync(tmp, OPENCLAW_CONFIG);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ========== 本地 Agent 文件操作 ==========
|
|
608
|
+
|
|
609
|
+
async listAgentsLocal() {
|
|
610
|
+
const agentsDir = path.join(OPENCLAW_HOME, 'agents');
|
|
611
|
+
const agents: any[] = [];
|
|
612
|
+
try {
|
|
613
|
+
if (fs.existsSync(agentsDir)) {
|
|
614
|
+
for (const d of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
|
615
|
+
if (d.isDirectory()) agents.push({ id: d.name, name: d.name });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
// silent
|
|
620
|
+
}
|
|
621
|
+
if (agents.length === 0) agents.push({ id: 'main', name: 'main' });
|
|
622
|
+
return { agents };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async getAgentFiles(agentId: string) {
|
|
626
|
+
const dir = path.join(OPENCLAW_HOME, 'agents', agentId, 'agent');
|
|
627
|
+
try {
|
|
628
|
+
return { files: fs.existsSync(dir) ? fs.readdirSync(dir) : [] };
|
|
629
|
+
} catch {
|
|
630
|
+
return { files: [] };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async getAgentFile(agentId: string, filename: string) {
|
|
635
|
+
const fp = path.join(OPENCLAW_HOME, 'agents', agentId, 'agent', filename);
|
|
636
|
+
try {
|
|
637
|
+
return { filename, content: fs.readFileSync(fp, 'utf-8') };
|
|
638
|
+
} catch {
|
|
639
|
+
return { filename, content: '' };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async setAgentFile(agentId: string, filename: string, content: string) {
|
|
644
|
+
const dir = path.join(OPENCLAW_HOME, 'agents', agentId, 'agent');
|
|
645
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
646
|
+
fs.writeFileSync(path.join(dir, filename), content, 'utf-8');
|
|
647
|
+
}
|
|
648
|
+
}
|