@wu529778790/open-im 1.9.3 → 1.9.4-beta.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.
@@ -42,9 +42,18 @@ export declare class WorkBuddyCentrifugeClient {
42
42
  private state;
43
43
  private processedMsgIds;
44
44
  private static readonly MAX_MSG_ID_CACHE;
45
+ private streamingActive;
46
+ private subscribedChannels;
47
+ private lastErrorMsg;
48
+ private lastErrorTime;
49
+ private pendingResponses;
45
50
  constructor(config: CentrifugeClientConfig, callbacks?: CentrifugeCallbacks);
46
51
  get logPrefix(): string;
47
52
  getState(): WorkBuddyState;
53
+ /** Enable/disable streaming mode: skip per-message channel registration during streaming. */
54
+ setStreamingMode(active: boolean): void;
55
+ /** Flush pending responses that failed to send. */
56
+ flushPendingResponses(): Promise<void>;
48
57
  start(): void;
49
58
  stop(): void;
50
59
  setCallbacks(callbacks: Partial<CentrifugeCallbacks>): void;
@@ -71,6 +80,8 @@ export declare class WorkBuddyCentrifugeClient {
71
80
  * Send prompt response (for WeChat KF, use HTTP instead)
72
81
  */
73
82
  sendPromptResponse(payload: PromptResponsePayload, _guid?: string, _userId?: string): Promise<void>;
83
+ /** Send HTTP COPILOT_RESPONSE with retry and pending queue fallback. */
84
+ private sendHttpResponse;
74
85
  /**
75
86
  * Handle incoming publication from Centrifuge
76
87
  */
@@ -15,6 +15,11 @@ export class WorkBuddyCentrifugeClient {
15
15
  state = 'disconnected';
16
16
  processedMsgIds = new Set();
17
17
  static MAX_MSG_ID_CACHE = 1000;
18
+ streamingActive = false;
19
+ subscribedChannels = new Set();
20
+ lastErrorMsg = '';
21
+ lastErrorTime = 0;
22
+ pendingResponses = [];
18
23
  constructor(config, callbacks = {}) {
19
24
  this.config = config;
20
25
  this.callbacks = callbacks;
@@ -25,6 +30,29 @@ export class WorkBuddyCentrifugeClient {
25
30
  getState() {
26
31
  return this.state;
27
32
  }
33
+ /** Enable/disable streaming mode: skip per-message channel registration during streaming. */
34
+ setStreamingMode(active) {
35
+ this.streamingActive = active;
36
+ if (!active) {
37
+ this.config.releaseChannelLockFn?.();
38
+ }
39
+ }
40
+ /** Flush pending responses that failed to send. */
41
+ async flushPendingResponses() {
42
+ if (this.pendingResponses.length === 0)
43
+ return;
44
+ const pending = [...this.pendingResponses];
45
+ this.pendingResponses = [];
46
+ log.info(`${this.logPrefix} Flushing ${pending.length} pending responses`);
47
+ for (const item of pending) {
48
+ try {
49
+ await this.sendHttpResponse(item.payload);
50
+ }
51
+ catch {
52
+ log.warn(`${this.logPrefix} Failed to flush pending response`);
53
+ }
54
+ }
55
+ }
28
56
  start() {
29
57
  if (this.state === 'connected' || this.state === 'connecting') {
30
58
  log.info(`${this.logPrefix} Already connected or connecting`);
@@ -55,8 +83,16 @@ export class WorkBuddyCentrifugeClient {
55
83
  }
56
84
  });
57
85
  this.client.on('error', (ctx) => {
58
- log.error(`${this.logPrefix} Error: ${ctx.error.message}`);
59
- this.callbacks.onError?.(new Error(ctx.error.message));
86
+ const msg = ctx.error?.message || String(ctx.error);
87
+ const now = Date.now();
88
+ // Suppress duplicate errors within 5 seconds to avoid log flooding
89
+ if (msg === this.lastErrorMsg && now - this.lastErrorTime < 5000) {
90
+ return;
91
+ }
92
+ this.lastErrorMsg = msg;
93
+ this.lastErrorTime = now;
94
+ log.error(`${this.logPrefix} Error: ${msg}`);
95
+ this.callbacks.onError?.(new Error(msg));
60
96
  });
61
97
  // Create channel subscription
62
98
  this.sub = this.client.newSubscription(this.config.channel, {
@@ -75,6 +111,10 @@ export class WorkBuddyCentrifugeClient {
75
111
  log.info(`${this.logPrefix} Stopping...`);
76
112
  this.state = 'disconnected';
77
113
  this.processedMsgIds.clear();
114
+ this.streamingActive = false;
115
+ this.subscribedChannels.clear();
116
+ this.lastErrorMsg = '';
117
+ this.pendingResponses = [];
78
118
  for (const sub of this.extraSubs) {
79
119
  sub.unsubscribe();
80
120
  }
@@ -100,6 +140,11 @@ export class WorkBuddyCentrifugeClient {
100
140
  log.warn(`${this.logPrefix} Cannot subscribe: client not initialized`);
101
141
  return;
102
142
  }
143
+ // Skip if already subscribed to this channel
144
+ if (this.subscribedChannels.has(channel)) {
145
+ log.info(`${this.logPrefix} Already subscribed to: ${channel}, skipping duplicate`);
146
+ return;
147
+ }
103
148
  log.info(`${this.logPrefix} Subscribing to additional channel: ${channel}`);
104
149
  const sub = this.client.newSubscription(channel, { token: subscriptionToken });
105
150
  sub.on('publication', (ctx) => {
@@ -112,6 +157,7 @@ export class WorkBuddyCentrifugeClient {
112
157
  log.info(`${this.logPrefix} Extra channel subscribed: ${channel}`);
113
158
  });
114
159
  this.extraSubs.push(sub);
160
+ this.subscribedChannels.add(channel);
115
161
  sub.subscribe();
116
162
  }
117
163
  /**
@@ -144,14 +190,10 @@ export class WorkBuddyCentrifugeClient {
144
190
  async sendPromptResponse(payload, _guid, _userId) {
145
191
  // WeChat KF messages: send via HTTP COPILOT_RESPONSE
146
192
  if (this.config.httpBaseUrl && this.config.httpAccessToken) {
147
- const message = payload.content?.map((c) => c.text).join('') || payload.error || '';
148
- const sessionId = payload.session_id; // e.g. "wmXXX::origin::wechatkfProxy"
149
- // The WorkBuddy server uses the registered channelId as the WeChat KF send_msg
150
- // `touser`. Re-register the channel with the current WeChat user's externalUserId
151
- // so that the server sends the reply to the correct customer.
193
+ const sessionId = payload.session_id;
194
+ // Register channel before sending (skip during streaming to reduce overhead)
152
195
  const externalUserId = sessionId.includes('::') ? sessionId.split('::')[0] : null;
153
- if (this.config.registerChannelFn && externalUserId) {
154
- // Retry registerChannelFn up to 3 times on network failure
196
+ if (this.config.registerChannelFn && externalUserId && !this.streamingActive) {
155
197
  for (let attempt = 1; attempt <= 3; attempt++) {
156
198
  try {
157
199
  await this.config.registerChannelFn(externalUserId);
@@ -168,61 +210,74 @@ export class WorkBuddyCentrifugeClient {
168
210
  }
169
211
  }
170
212
  }
171
- const httpPayload = {
172
- type: 'COPILOT_RESPONSE',
173
- msgId: payload.prompt_id,
174
- chatId: sessionId,
175
- success: payload.stop_reason === 'end_turn',
176
- message,
177
- metadata: {
178
- sessionId: this.config.workspaceSessionId || sessionId,
179
- requestId: payload.prompt_id,
180
- state: payload.stop_reason === 'end_turn' ? 'completed' : payload.stop_reason,
181
- },
182
- };
183
- const url = `${this.config.httpBaseUrl}/v2/backgroundagent/wecom/local-proxy/receive`;
184
- log.debug(`${this.logPrefix} HTTP COPILOT_RESPONSE ${url} chatId=${sessionId} msgLen=${message.length}`);
185
- // Retry COPILOT_RESPONSE up to 3 times on network failure
186
- let sent = false;
187
- for (let attempt = 1; attempt <= 3; attempt++) {
188
- try {
189
- const res = await fetch(url, {
190
- method: 'POST',
191
- headers: {
192
- 'Content-Type': 'application/json',
193
- Authorization: `Bearer ${this.config.httpAccessToken}`,
194
- },
195
- body: JSON.stringify(httpPayload),
196
- signal: AbortSignal.timeout(30_000),
197
- });
198
- const body = await res.text().catch(() => '');
199
- if (!res.ok) {
200
- log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE failed: ${res.status} ${body.substring(0, 300)}`);
201
- }
202
- else {
203
- log.info(`${this.logPrefix} HTTP COPILOT_RESPONSE ok: ${res.status} ${body.substring(0, 200)}`);
204
- }
205
- sent = true;
206
- break;
213
+ await this.sendHttpResponse(payload);
214
+ // Release heartbeat lock (skip during streaming — released when streaming ends)
215
+ if (!this.streamingActive) {
216
+ this.config.releaseChannelLockFn?.();
217
+ }
218
+ return;
219
+ }
220
+ this.sendEnvelope('session.promptResponse', payload, _guid, _userId);
221
+ }
222
+ /** Send HTTP COPILOT_RESPONSE with retry and pending queue fallback. */
223
+ async sendHttpResponse(payload) {
224
+ const message = payload.content?.map((c) => c.text).join('') || payload.error || '';
225
+ const sessionId = payload.session_id;
226
+ const isStreaming = payload.stop_reason === 'streaming';
227
+ const logLevel = isStreaming ? 'debug' : 'info';
228
+ const httpPayload = {
229
+ type: 'COPILOT_RESPONSE',
230
+ msgId: payload.prompt_id,
231
+ chatId: sessionId,
232
+ success: payload.stop_reason === 'end_turn' || isStreaming,
233
+ message,
234
+ metadata: {
235
+ sessionId: this.config.workspaceSessionId || sessionId,
236
+ requestId: payload.prompt_id,
237
+ state: payload.stop_reason === 'end_turn' ? 'completed' : payload.stop_reason,
238
+ },
239
+ };
240
+ const url = `${this.config.httpBaseUrl}/v2/backgroundagent/wecom/local-proxy/receive`;
241
+ log.debug(`${this.logPrefix} HTTP COPILOT_RESPONSE → chatId=${sessionId} msgLen=${message.length} streaming=${isStreaming}`);
242
+ const maxAttempts = isStreaming ? 2 : 5;
243
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
244
+ try {
245
+ const res = await fetch(url, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ Authorization: `Bearer ${this.config.httpAccessToken}`,
250
+ },
251
+ body: JSON.stringify(httpPayload),
252
+ signal: AbortSignal.timeout(30_000),
253
+ });
254
+ const body = await res.text().catch(() => '');
255
+ if (!res.ok) {
256
+ log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE failed: ${res.status} ${body.substring(0, 300)}`);
207
257
  }
208
- catch (err) {
209
- if (attempt < 3) {
210
- log.warn(`${this.logPrefix} HTTP COPILOT_RESPONSE attempt ${attempt} failed, retrying in 2s:`, err);
211
- await new Promise((r) => setTimeout(r, 2000));
212
- }
213
- else {
214
- log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE error after 3 attempts:`, err);
215
- }
258
+ else if (!isStreaming) {
259
+ log.info(`${this.logPrefix} HTTP COPILOT_RESPONSE ok: ${res.status} ${body.substring(0, 200)}`);
216
260
  }
261
+ return; // sent successfully
217
262
  }
218
- if (!sent) {
219
- log.error(`${this.logPrefix} Failed to send COPILOT_RESPONSE after retries`);
263
+ catch (err) {
264
+ if (attempt < maxAttempts) {
265
+ const delay = 2000 * attempt;
266
+ log.warn(`${this.logPrefix} HTTP COPILOT_RESPONSE attempt ${attempt} failed, retrying in ${delay}ms:`, err);
267
+ await new Promise((r) => setTimeout(r, delay));
268
+ }
269
+ else {
270
+ log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE error after ${maxAttempts} attempts:`, err);
271
+ }
220
272
  }
221
- // Release the heartbeat lock so the periodic registration can resume
222
- this.config.releaseChannelLockFn?.();
223
- return;
224
273
  }
225
- this.sendEnvelope('session.promptResponse', payload, _guid, _userId);
274
+ // All retries failed — queue for later (only non-streaming responses)
275
+ if (!isStreaming) {
276
+ this.pendingResponses.push({ payload, timestamp: Date.now() });
277
+ if (this.pendingResponses.length > 10)
278
+ this.pendingResponses.shift();
279
+ log.warn(`${this.logPrefix} Queued response for retry (queue: ${this.pendingResponses.length})`);
280
+ }
226
281
  }
227
282
  /**
228
283
  * Handle incoming publication from Centrifuge
@@ -4,7 +4,7 @@
4
4
  import { resolvePlatformAiCommand } from '../config.js';
5
5
  import { AccessControl } from '../access/access-control.js';
6
6
  import { RequestQueue } from '../queue/request-queue.js';
7
- import { sendTextReply, sendErrorReply } from './message-sender.js';
7
+ import { sendTextReply, sendErrorReply, sendStreamingReply } from './message-sender.js';
8
8
  import { CommandHandler } from '../commands/handler.js';
9
9
  import { getAdapter } from '../adapters/registry.js';
10
10
  import { runAITask } from '../shared/ai-task.js';
@@ -12,6 +12,7 @@ import { WORKBUDDY_THROTTLE_MS } from '../constants.js';
12
12
  import { setActiveChatId } from '../shared/active-chats.js';
13
13
  import { setChatUser } from '../shared/chat-user-map.js';
14
14
  import { createLogger } from '../logger.js';
15
+ import { getCentrifugeClient } from './client.js';
15
16
  const log = createLogger('WorkBuddyHandler');
16
17
  export function setupWorkBuddyHandlers(config, sessionManager) {
17
18
  const accessControl = new AccessControl(config.workbuddyAllowedUserIds);
@@ -42,14 +43,20 @@ export function setupWorkBuddyHandlers(config, sessionManager) {
42
43
  const taskKey = `${userId}:${msgId}`;
43
44
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'workbuddy', taskKey }, prompt, toolAdapter, {
44
45
  throttleMs: WORKBUDDY_THROTTLE_MS,
46
+ minContentDeltaChars: 200,
45
47
  streamUpdate: async (content) => {
46
- // WorkBuddy doesn't support streaming updates via Centrifuge
47
- log.debug(`Stream update (not sent): ${content.substring(0, 50)}...`);
48
+ await sendStreamingReply(null, chatId, content, msgId);
48
49
  },
49
50
  sendComplete: async (content) => {
51
+ const client = getCentrifugeClient();
52
+ if (client)
53
+ client.setStreamingMode(false);
50
54
  await sendTextReply(null, chatId, content, msgId);
51
55
  },
52
56
  sendError: async (error) => {
57
+ const client = getCentrifugeClient();
58
+ if (client)
59
+ client.setStreamingMode(false);
53
60
  await sendErrorReply(null, chatId, error, msgId);
54
61
  },
55
62
  extraCleanup: () => {
@@ -62,6 +69,12 @@ export function setupWorkBuddyHandlers(config, sessionManager) {
62
69
  runningTasks.set(taskKey, state);
63
70
  taskKeyByChatId.set(chatId, taskKey);
64
71
  },
72
+ onFirstContent: () => {
73
+ // Enable streaming mode: register channel once, then skip per-update registration
74
+ const client = getCentrifugeClient();
75
+ if (client)
76
+ client.setStreamingMode(true);
77
+ },
65
78
  });
66
79
  }
67
80
  async function handleEvent(chatId, msgId, content) {
@@ -14,3 +14,8 @@ export declare function sendErrorReply(_client: WorkBuddyCentrifugeClient | null
14
14
  * Send streaming chunk to WeChat KF
15
15
  */
16
16
  export declare function sendStreamingChunk(_client: WorkBuddyCentrifugeClient | null, chatId: string, text: string, msgId: string): void;
17
+ /**
18
+ * Send streaming reply to WeChat KF via HTTP COPILOT_RESPONSE.
19
+ * Used for intermediate progress updates during AI task execution.
20
+ */
21
+ export declare function sendStreamingReply(_client: WorkBuddyCentrifugeClient | null, chatId: string, text: string, msgId: string): Promise<void>;
@@ -49,3 +49,20 @@ export function sendStreamingChunk(_client, chatId, text, msgId) {
49
49
  }
50
50
  client.sendMessageChunk(chatId, msgId, { type: 'text', text });
51
51
  }
52
+ /**
53
+ * Send streaming reply to WeChat KF via HTTP COPILOT_RESPONSE.
54
+ * Used for intermediate progress updates during AI task execution.
55
+ */
56
+ export async function sendStreamingReply(_client, chatId, text, msgId) {
57
+ const client = _client ?? getCentrifugeClient();
58
+ if (!client) {
59
+ log.debug('WorkBuddy client not available, skipping streaming reply');
60
+ return;
61
+ }
62
+ await client.sendPromptResponse({
63
+ session_id: chatId,
64
+ prompt_id: msgId,
65
+ content: [{ type: 'text', text }],
66
+ stop_reason: 'streaming',
67
+ });
68
+ }
@@ -82,5 +82,5 @@ export interface PromptResponsePayload {
82
82
  text?: string;
83
83
  }>;
84
84
  error?: string;
85
- stop_reason: 'end_turn' | 'max_tokens' | 'tool_use' | 'stop_sequence' | 'error';
85
+ stop_reason: 'end_turn' | 'max_tokens' | 'tool_use' | 'stop_sequence' | 'error' | 'streaming';
86
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.9.3",
3
+ "version": "1.9.4-beta.0",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",