palz-connector 1.0.2 → 1.0.4
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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugin.ts +155 -61
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/plugin.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import WebSocket from 'ws';
|
|
9
9
|
import { readFileSync } from 'fs';
|
|
10
|
-
import { resolve, dirname
|
|
10
|
+
import { resolve, dirname } from 'path';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
|
|
14
14
|
// ============ 类型定义 ============
|
|
15
15
|
|
|
@@ -49,23 +49,12 @@ function getPluginDir(): string {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function readGatewayToken(log?: any): string {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const token = openclawConfig?.gateway?.auth?.token;
|
|
56
|
-
if (token) {
|
|
57
|
-
log?.info?.('[Palz] gatewayToken 从 ~/.openclaw/openclaw.json 读取');
|
|
58
|
-
return token;
|
|
59
|
-
}
|
|
60
|
-
} catch {
|
|
61
|
-
// 文件不存在或解析失败,继续尝试环境变量
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (process.env.GATEWAY_TOKEN) {
|
|
65
|
-
return process.env.GATEWAY_TOKEN;
|
|
52
|
+
if (process.env.OPENCLAW_GATEWAY_TOKEN) {
|
|
53
|
+
log?.info?.('[Palz] gatewayToken 从环境变量 OPENCLAW_GATEWAY_TOKEN 读取');
|
|
54
|
+
return process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
66
55
|
}
|
|
67
56
|
|
|
68
|
-
log?.warn?.('[Palz] gatewayToken
|
|
57
|
+
log?.warn?.('[Palz] gatewayToken 未配置(环境变量 OPENCLAW_GATEWAY_TOKEN 未设置)');
|
|
69
58
|
return '';
|
|
70
59
|
}
|
|
71
60
|
|
|
@@ -153,36 +142,28 @@ function isNewSessionCommand(text: string): boolean {
|
|
|
153
142
|
|
|
154
143
|
function getSessionKey(
|
|
155
144
|
senderId: string,
|
|
145
|
+
conversationId: string,
|
|
156
146
|
forceNew: boolean,
|
|
157
|
-
sessionTimeout: number,
|
|
158
147
|
log?: any,
|
|
159
148
|
): { sessionKey: string; isNew: boolean } {
|
|
149
|
+
const sessionId = `palz:${senderId}:${conversationId}`;
|
|
160
150
|
const now = Date.now();
|
|
161
151
|
|
|
162
152
|
if (forceNew) {
|
|
163
|
-
const sessionId = `palz:${senderId}:${now}`;
|
|
164
153
|
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
165
|
-
log?.info?.(`[Palz][Session] 用户主动开启新会话: ${senderId}`);
|
|
154
|
+
log?.info?.(`[Palz][Session] 用户主动开启新会话: ${senderId}, conversation=${conversationId}`);
|
|
166
155
|
return { sessionKey: sessionId, isNew: true };
|
|
167
156
|
}
|
|
168
157
|
|
|
169
158
|
const existing = userSessions.get(senderId);
|
|
170
|
-
if (existing) {
|
|
171
|
-
const elapsed = now - existing.lastActivity;
|
|
172
|
-
if (elapsed > sessionTimeout) {
|
|
173
|
-
const sessionId = `palz:${senderId}:${now}`;
|
|
174
|
-
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
175
|
-
log?.info?.(`[Palz][Session] 会话超时(${Math.round(elapsed / 60000)}分钟),自动开启新会话: ${senderId}`);
|
|
176
|
-
return { sessionKey: sessionId, isNew: true };
|
|
177
|
-
}
|
|
159
|
+
if (existing && existing.sessionId === sessionId) {
|
|
178
160
|
existing.lastActivity = now;
|
|
179
|
-
return { sessionKey:
|
|
161
|
+
return { sessionKey: sessionId, isNew: false };
|
|
180
162
|
}
|
|
181
163
|
|
|
182
|
-
const sessionId = `palz:${senderId}:${now}`;
|
|
183
164
|
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
184
|
-
log?.info?.(`[Palz][Session]
|
|
185
|
-
return { sessionKey: sessionId, isNew:
|
|
165
|
+
log?.info?.(`[Palz][Session] 新会话: ${senderId}, conversation=${conversationId}`);
|
|
166
|
+
return { sessionKey: sessionId, isNew: !existing };
|
|
186
167
|
}
|
|
187
168
|
|
|
188
169
|
// ============ Gateway SSE 客户端 ============
|
|
@@ -198,7 +179,18 @@ async function* streamFromGateway(
|
|
|
198
179
|
const rt = getRuntime();
|
|
199
180
|
const gatewayUrl = `http://127.0.0.1:${rt.gateway?.port || 18789}/v1/chat/completions`;
|
|
200
181
|
|
|
201
|
-
|
|
182
|
+
const requestBody = {
|
|
183
|
+
model: 'main',
|
|
184
|
+
messages: [{ role: 'user', content: userContent }],
|
|
185
|
+
stream: true,
|
|
186
|
+
user: sessionKey,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const contentPreview = typeof userContent === 'string'
|
|
190
|
+
? userContent.slice(0, 200)
|
|
191
|
+
: JSON.stringify(userContent).slice(0, 200);
|
|
192
|
+
log?.info?.(`[Palz][Gateway] POST ${gatewayUrl}, session=${sessionKey}, content=${contentPreview}`);
|
|
193
|
+
log?.info?.(`[Palz][Gateway] Request body: ${JSON.stringify(requestBody, null, 2)}`);
|
|
202
194
|
|
|
203
195
|
const controller = new AbortController();
|
|
204
196
|
const timer = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT);
|
|
@@ -211,27 +203,28 @@ async function* streamFromGateway(
|
|
|
211
203
|
'Content-Type': 'application/json',
|
|
212
204
|
...(gatewayAuth ? { Authorization: `Bearer ${gatewayAuth}` } : {}),
|
|
213
205
|
},
|
|
214
|
-
body: JSON.stringify(
|
|
215
|
-
model: 'main',
|
|
216
|
-
messages: [{ role: 'user', content: userContent }],
|
|
217
|
-
stream: true,
|
|
218
|
-
user: sessionKey,
|
|
219
|
-
}),
|
|
206
|
+
body: JSON.stringify(requestBody),
|
|
220
207
|
signal: controller.signal,
|
|
221
208
|
});
|
|
222
209
|
} catch (err: any) {
|
|
223
210
|
clearTimeout(timer);
|
|
211
|
+
log?.error?.(`[Palz][Gateway] 请求异常: ${err.message}`);
|
|
224
212
|
throw err;
|
|
225
213
|
}
|
|
226
214
|
|
|
215
|
+
log?.info?.(`[Palz][Gateway] Response status=${response.status}, headers=${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
216
|
+
|
|
227
217
|
if (!response.ok || !response.body) {
|
|
228
218
|
clearTimeout(timer);
|
|
229
|
-
|
|
219
|
+
const errorBody = await response.text().catch(() => '');
|
|
220
|
+
log?.error?.(`[Palz][Gateway] 请求失败: status=${response.status}, body=${errorBody}`);
|
|
221
|
+
throw new Error(`Gateway error: ${response.status} ${errorBody}`);
|
|
230
222
|
}
|
|
231
223
|
|
|
232
224
|
const reader = (response.body as any).getReader();
|
|
233
225
|
const decoder = new TextDecoder();
|
|
234
226
|
let buffer = '';
|
|
227
|
+
let fullResponse = '';
|
|
235
228
|
|
|
236
229
|
try {
|
|
237
230
|
while (true) {
|
|
@@ -243,16 +236,23 @@ async function* streamFromGateway(
|
|
|
243
236
|
for (const line of lines) {
|
|
244
237
|
if (!line.startsWith('data: ')) continue;
|
|
245
238
|
const data = line.slice(6).trim();
|
|
246
|
-
if (data === '[DONE]')
|
|
239
|
+
if (data === '[DONE]') {
|
|
240
|
+
log?.info?.(`[Palz][Gateway] 流式响应完成, 总长度=${fullResponse.length}, 内容=${fullResponse.slice(0, 500)}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
247
243
|
try {
|
|
248
244
|
const chunk = JSON.parse(data);
|
|
249
245
|
const content = chunk.choices?.[0]?.delta?.content;
|
|
250
|
-
if (content)
|
|
246
|
+
if (content) {
|
|
247
|
+
fullResponse += content;
|
|
248
|
+
yield content;
|
|
249
|
+
}
|
|
251
250
|
} catch {
|
|
252
|
-
|
|
251
|
+
log?.warn?.(`[Palz][Gateway] 解析SSE数据失败: ${data.slice(0, 200)}`);
|
|
253
252
|
}
|
|
254
253
|
}
|
|
255
254
|
}
|
|
255
|
+
log?.info?.(`[Palz][Gateway] 流结束, 总长度=${fullResponse.length}, 内容=${fullResponse.slice(0, 500)}`);
|
|
256
256
|
} finally {
|
|
257
257
|
clearTimeout(timer);
|
|
258
258
|
}
|
|
@@ -260,20 +260,54 @@ async function* streamFromGateway(
|
|
|
260
260
|
|
|
261
261
|
// ============ IM 消息发送 ============
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
const STREAM_THROTTLE_MS = 200;
|
|
264
|
+
|
|
265
|
+
interface StreamOpts {
|
|
266
|
+
streamId: string;
|
|
267
|
+
seq: number;
|
|
268
|
+
isFinal: boolean;
|
|
269
|
+
delta: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let msgSeq = 0;
|
|
273
|
+
|
|
274
|
+
function nextMsgId(): string {
|
|
275
|
+
return `bot_reply_${Date.now()}_${++msgSeq}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function sendToIM(
|
|
279
|
+
config: any,
|
|
280
|
+
conversationId: string,
|
|
281
|
+
content: OpenAIContent,
|
|
282
|
+
log?: any,
|
|
283
|
+
stream?: StreamOpts,
|
|
284
|
+
conversationType: string = 'direct',
|
|
285
|
+
) {
|
|
264
286
|
const botId = config.botId;
|
|
265
287
|
const url = `${config.apiBaseUrl}/bot/send`;
|
|
266
288
|
const contentLength = typeof content === 'string' ? content.length : JSON.stringify(content).length;
|
|
267
|
-
|
|
289
|
+
|
|
290
|
+
const reqBody: Record<string, unknown> = {
|
|
291
|
+
bot_id: botId,
|
|
292
|
+
conversation_id: conversationId,
|
|
293
|
+
conversation_type: conversationType,
|
|
294
|
+
msg_id: nextMsgId(),
|
|
295
|
+
content,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
if (stream) {
|
|
299
|
+
reqBody.stream_id = stream.streamId;
|
|
300
|
+
reqBody.seq = stream.seq;
|
|
301
|
+
reqBody.is_final = stream.isFinal;
|
|
302
|
+
reqBody.delta = stream.delta;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
log?.info?.(`[Palz][Send] POST ${url}, bot_id=${botId}, conversation=${conversationId}, msg_id=${reqBody.msg_id}, length=${contentLength}${stream ? `, stream=${stream.streamId}, seq=${stream.seq}, final=${stream.isFinal}, delta_len=${stream.delta.length}` : ''}`);
|
|
268
306
|
|
|
269
307
|
const response = await fetch(url, {
|
|
270
308
|
method: 'POST',
|
|
271
309
|
headers: { 'Content-Type': 'application/json' },
|
|
272
|
-
body: JSON.stringify(
|
|
273
|
-
bot_id: botId,
|
|
274
|
-
conversation_id: conversationId,
|
|
275
|
-
content,
|
|
276
|
-
}),
|
|
310
|
+
body: JSON.stringify(reqBody),
|
|
277
311
|
});
|
|
278
312
|
|
|
279
313
|
const body = await response.json().catch(() => null);
|
|
@@ -285,6 +319,55 @@ async function sendToIM(config: any, conversationId: string, content: OpenAICont
|
|
|
285
319
|
return body;
|
|
286
320
|
}
|
|
287
321
|
|
|
322
|
+
/**
|
|
323
|
+
* 流式回复:边从 Gateway 读取边向 IM 发送,带节流控制。
|
|
324
|
+
* 每个片段同时携带 content(完整累积)和 delta(本次增量),
|
|
325
|
+
* IM 端可按需选择:用 delta 追加渲染(高效),或用 content 全量替换(可靠)。
|
|
326
|
+
*/
|
|
327
|
+
async function streamReplyToIM(
|
|
328
|
+
config: any,
|
|
329
|
+
conversationId: string,
|
|
330
|
+
gateway: AsyncGenerator<string>,
|
|
331
|
+
log?: any,
|
|
332
|
+
conversationType: string = 'direct',
|
|
333
|
+
) {
|
|
334
|
+
const streamId = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
335
|
+
let fullResponse = '';
|
|
336
|
+
let lastSentLength = 0;
|
|
337
|
+
let lastSendTime = 0;
|
|
338
|
+
let seq = 0;
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
for await (const chunk of gateway) {
|
|
342
|
+
fullResponse += chunk;
|
|
343
|
+
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
if (now - lastSendTime >= STREAM_THROTTLE_MS) {
|
|
346
|
+
const delta = fullResponse.slice(lastSentLength);
|
|
347
|
+
await sendToIM(config, conversationId, fullResponse, log, {
|
|
348
|
+
streamId, seq: seq++, isFinal: false, delta,
|
|
349
|
+
}, conversationType);
|
|
350
|
+
lastSentLength = fullResponse.length;
|
|
351
|
+
lastSendTime = now;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const finalDelta = fullResponse.slice(lastSentLength);
|
|
356
|
+
await sendToIM(config, conversationId, fullResponse || '(无响应)', log, {
|
|
357
|
+
streamId, seq: seq++, isFinal: true, delta: finalDelta,
|
|
358
|
+
}, conversationType);
|
|
359
|
+
} catch (err: any) {
|
|
360
|
+
log?.error?.(`[Palz][Stream] 流式回复异常: ${err.message}`);
|
|
361
|
+
if (fullResponse) {
|
|
362
|
+
const errSuffix = `\n\n[错误: ${err.message}]`;
|
|
363
|
+
await sendToIM(config, conversationId, fullResponse + errSuffix, log, {
|
|
364
|
+
streamId, seq: seq++, isFinal: true, delta: fullResponse.slice(lastSentLength) + errSuffix,
|
|
365
|
+
}, conversationType).catch(() => {});
|
|
366
|
+
}
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
288
371
|
// ============ 核心消息处理 ============
|
|
289
372
|
|
|
290
373
|
function handleIMMessage(params: {
|
|
@@ -306,29 +389,40 @@ function handleIMMessage(params: {
|
|
|
306
389
|
|
|
307
390
|
const senderId = msg.sender_id;
|
|
308
391
|
const conversationId = msg.conversation_id;
|
|
309
|
-
|
|
392
|
+
const conversationType = msg.conversation_type || 'direct';
|
|
393
|
+
const useStream = msg.stream === true;
|
|
394
|
+
const msgSnapshot = Object.fromEntries(
|
|
395
|
+
Object.entries(msg).map(([k, v]) => {
|
|
396
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
397
|
+
return [k, s.length > 300 ? s.slice(0, 300) + '...' : v];
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
log?.info?.(`[Palz] 收到消息: ${JSON.stringify(msgSnapshot)}, stream=${useStream}`);
|
|
310
401
|
|
|
311
402
|
withUserLock(senderId, async () => {
|
|
312
|
-
const sessionTimeout = config.sessionTimeout ?? 1800000;
|
|
313
|
-
|
|
314
403
|
if (isNewSessionCommand(plainText)) {
|
|
315
|
-
getSessionKey(senderId,
|
|
316
|
-
await sendToIM(config, conversationId, '✨ 已开启新会话', log);
|
|
404
|
+
getSessionKey(senderId, conversationId, true, log);
|
|
405
|
+
await sendToIM(config, conversationId, '✨ 已开启新会话', log, undefined, conversationType);
|
|
317
406
|
return;
|
|
318
407
|
}
|
|
319
408
|
|
|
320
|
-
const { sessionKey } = getSessionKey(senderId,
|
|
409
|
+
const { sessionKey } = getSessionKey(senderId, conversationId, false, log);
|
|
321
410
|
const gatewayAuth = config.gatewayToken || '';
|
|
322
411
|
|
|
323
|
-
let fullResponse = '';
|
|
324
412
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
413
|
+
const gateway = streamFromGateway(content, sessionKey, gatewayAuth, log);
|
|
414
|
+
if (useStream) {
|
|
415
|
+
await streamReplyToIM(config, conversationId, gateway, log, conversationType);
|
|
416
|
+
} else {
|
|
417
|
+
let fullResponse = '';
|
|
418
|
+
for await (const chunk of gateway) {
|
|
419
|
+
fullResponse += chunk;
|
|
420
|
+
}
|
|
421
|
+
await sendToIM(config, conversationId, fullResponse || '(无响应)', log, undefined, conversationType);
|
|
327
422
|
}
|
|
328
|
-
await sendToIM(config, conversationId, fullResponse || '(无响应)', log);
|
|
329
423
|
} catch (err: any) {
|
|
330
424
|
log?.error?.(`[Palz][Gateway] 调用失败: ${err.message}`);
|
|
331
|
-
await sendToIM(config, conversationId, `抱歉,处理请求时出错: ${err.message}`, log);
|
|
425
|
+
await sendToIM(config, conversationId, `抱歉,处理请求时出错: ${err.message}`, log, undefined, conversationType);
|
|
332
426
|
}
|
|
333
427
|
});
|
|
334
428
|
}
|