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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "palz-connector",
3
3
  "name": "Palz Connector Channel",
4
- "version": "1.0.2",
4
+ "version": "1.0.4",
5
5
  "description": "Palz IM 接入 OpenClaw",
6
6
  "channels": ["palz-connector"],
7
7
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palz-connector",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Palz IM 接入 OpenClaw",
5
5
  "main": "plugin.ts",
6
6
  "type": "module",
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, join } from 'path';
10
+ import { resolve, dirname } from 'path';
11
11
  import { fileURLToPath } from 'url';
12
- import { homedir } from 'os';
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
- try {
53
- const openclawPath = join(homedir(), '.openclaw', 'openclaw.json');
54
- const openclawConfig = JSON.parse(readFileSync(openclawPath, 'utf-8'));
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 未配置(~/.openclaw/openclaw.json 和环境变量 GATEWAY_TOKEN 均未设置)');
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: existing.sessionId, isNew: false };
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] 新用户首次会话: ${senderId}`);
185
- return { sessionKey: sessionId, isNew: false };
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
- log?.info?.(`[Palz][Gateway] POST ${gatewayUrl}, session=${sessionKey}`);
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
- throw new Error(`Gateway error: ${response.status}`);
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]') return;
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) yield content;
246
+ if (content) {
247
+ fullResponse += content;
248
+ yield content;
249
+ }
251
250
  } catch {
252
- // skip malformed JSON chunks
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
- async function sendToIM(config: any, conversationId: string, content: OpenAIContent, log?: any) {
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
- log?.info?.(`[Palz][Send] POST ${url}, bot_id=${botId}, conversation=${conversationId}, length=${contentLength}`);
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
- log?.info?.(`[Palz] 收到消息: sender=${senderId}, bot_id=${config.botId}, conv=${conversationId}`);
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, true, sessionTimeout, log);
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, false, sessionTimeout, log);
409
+ const { sessionKey } = getSessionKey(senderId, conversationId, false, log);
321
410
  const gatewayAuth = config.gatewayToken || '';
322
411
 
323
- let fullResponse = '';
324
412
  try {
325
- for await (const chunk of streamFromGateway(content, sessionKey, gatewayAuth, log)) {
326
- fullResponse += chunk;
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
  }