@ryantest/openclaw-qqbot 1.6.6-alpha.0 → 1.6.6-alpha.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/dist/src/api.js CHANGED
@@ -332,6 +332,27 @@ async function completeUploadWithRetry(accessToken, method, path, body) {
332
332
  }
333
333
  throw lastError;
334
334
  }
335
+ // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
336
+ const PART_FINISH_MAX_RETRIES = 2;
337
+ const PART_FINISH_BASE_DELAY_MS = 1000;
338
+ async function partFinishWithRetry(accessToken, method, path, body) {
339
+ let lastError = null;
340
+ for (let attempt = 0; attempt <= PART_FINISH_MAX_RETRIES; attempt++) {
341
+ try {
342
+ await apiRequest(accessToken, method, path, body);
343
+ return;
344
+ }
345
+ catch (err) {
346
+ lastError = err instanceof Error ? err : new Error(String(err));
347
+ if (attempt < PART_FINISH_MAX_RETRIES) {
348
+ const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
349
+ console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
350
+ await new Promise(resolve => setTimeout(resolve, delay));
351
+ }
352
+ }
353
+ }
354
+ throw lastError;
355
+ }
335
356
  export async function getGatewayUrl(accessToken) {
336
357
  const data = await apiRequest(accessToken, "GET", "/gateway");
337
358
  return data.url;
@@ -467,7 +488,7 @@ export async function c2cUploadPrepare(accessToken, userId, fileType, fileName,
467
488
  * @param md5 - 分片数据的 MD5(十六进制)
468
489
  */
469
490
  export async function c2cUploadPartFinish(accessToken, userId, uploadId, partIndex, blockSize, md5) {
470
- await apiRequest(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
491
+ await partFinishWithRetry(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
471
492
  }
472
493
  /**
473
494
  * 完成文件上传(C2C)
@@ -493,7 +514,7 @@ export async function groupUploadPrepare(accessToken, groupId, fileType, fileNam
493
514
  * POST /v2/groups/{group_id}/upload_part_finish
494
515
  */
495
516
  export async function groupUploadPartFinish(accessToken, groupId, uploadId, partIndex, blockSize, md5) {
496
- await apiRequest(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
517
+ await partFinishWithRetry(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
497
518
  }
498
519
  /**
499
520
  * 完成文件上传(Group)
@@ -73,6 +73,12 @@ export declare class StreamingController {
73
73
  * - onIdle 校验时用于前缀匹配
74
74
  */
75
75
  private lastNormalizedFull;
76
+ /**
77
+ * 最后一次收到的完整原始文本(未经 normalize)。
78
+ * 仅用于回复边界检测——原始文本在 partial reply 过程中是稳定递增的,
79
+ * 不会因为 normalizeMediaTags 对未闭合标签的处理差异导致前缀不匹配。
80
+ */
81
+ private lastRawFull;
76
82
  /**
77
83
  * 在 lastNormalizedFull 中已经"消费"到的位置。
78
84
  * "消费"包括:已通过流式发送并终结的文本段、已处理的媒体标签。
@@ -179,7 +179,7 @@ class FlushController {
179
179
  export class StreamingController {
180
180
  // ---- 状态机 ----
181
181
  phase = "idle";
182
- // ---- 核心文本状态(仅两个) ----
182
+ // ---- 核心文本状态 ----
183
183
  /**
184
184
  * 最后一次收到的完整 normalized 全量文本。
185
185
  * - onPartialReply 每次更新(回复边界时会拼接前缀)
@@ -187,6 +187,12 @@ export class StreamingController {
187
187
  * - onIdle 校验时用于前缀匹配
188
188
  */
189
189
  lastNormalizedFull = "";
190
+ /**
191
+ * 最后一次收到的完整原始文本(未经 normalize)。
192
+ * 仅用于回复边界检测——原始文本在 partial reply 过程中是稳定递增的,
193
+ * 不会因为 normalizeMediaTags 对未闭合标签的处理差异导致前缀不匹配。
194
+ */
195
+ lastRawFull = "";
190
196
  /**
191
197
  * 在 lastNormalizedFull 中已经"消费"到的位置。
192
198
  * "消费"包括:已通过流式发送并终结的文本段、已处理的媒体标签。
@@ -352,12 +358,11 @@ export class StreamingController {
352
358
  this.logDebug(`onPartialReply: skipped (empty text)`);
353
359
  return;
354
360
  }
355
- // ★ 回复边界检测:新文本与上次处理的内容前缀不同 新回复开始
356
- // 比较方式:新文本 normalize 后,检查是否以上次的 lastNormalizedFull 为前缀
357
- // 如果不是前缀关系(内容发生了非追加的变化),终结当前 controller,通知调用方创建新的
358
- const normalized = normalizeMediaTags(text);
359
- if (this.lastNormalizedFull && normalized.length > 0 && !normalized.startsWith(this.lastNormalizedFull)) {
360
- this.logInfo(`onPartialReply: reply boundary detected — prefix mismatch (new len=${normalized.length}, prev len=${this.lastNormalizedFull.length}), finalizing current controller`);
361
+ // ★ 回复边界检测:用原始文本做前缀比较,避免 normalizeMediaTags 对未闭合标签
362
+ // 的不稳定处理导致误判(normalize 后的文本在 partial reply 的不同阶段可能产生
363
+ // 完全不同的结果,从而使 startsWith 始终失败,导致 boundary 被反复触发)
364
+ if (this.lastRawFull && text.length > 0 && !text.startsWith(this.lastRawFull)) {
365
+ this.logInfo(`onPartialReply: reply boundary detected raw prefix mismatch (new len=${text.length}, prev len=${this.lastRawFull.length}), finalizing current controller`);
361
366
  // 终结当前流式会话,处理完当前内容(包括可能的未闭合媒体标签)
362
367
  this.dispatchFullyComplete = true;
363
368
  await this.finalizeOnIdle();
@@ -371,8 +376,10 @@ export class StreamingController {
371
376
  }
372
377
  return;
373
378
  }
374
- // 正常增长:使用已 normalize 的文本
375
- this.lastNormalizedFull = normalized;
379
+ // 正常增长:更新原始文本和 normalize 后的文本
380
+ this.lastRawFull = text;
381
+ this.lastNormalizedFull = normalizeMediaTags(text);
382
+ ;
376
383
  // ★ 核心:从 sentIndex 开始,处理增量文本(串行队列保证不会并发进入)
377
384
  await this.processMediaTags(this.lastNormalizedFull);
378
385
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryantest/openclaw-qqbot",
3
- "version": "1.6.6-alpha.0",
3
+ "version": "1.6.6-alpha.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -62,4 +62,4 @@
62
62
  "silk-wasm",
63
63
  "ws"
64
64
  ]
65
- }
65
+ }
package/src/api.ts CHANGED
@@ -413,6 +413,37 @@ async function completeUploadWithRetry(
413
413
  throw lastError!;
414
414
  }
415
415
 
416
+ // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
417
+
418
+ const PART_FINISH_MAX_RETRIES = 2;
419
+ const PART_FINISH_BASE_DELAY_MS = 1000;
420
+
421
+ async function partFinishWithRetry(
422
+ accessToken: string,
423
+ method: string,
424
+ path: string,
425
+ body?: unknown,
426
+ ): Promise<void> {
427
+ let lastError: Error | null = null;
428
+
429
+ for (let attempt = 0; attempt <= PART_FINISH_MAX_RETRIES; attempt++) {
430
+ try {
431
+ await apiRequest<Record<string, unknown>>(accessToken, method, path, body);
432
+ return;
433
+ } catch (err) {
434
+ lastError = err instanceof Error ? err : new Error(String(err));
435
+
436
+ if (attempt < PART_FINISH_MAX_RETRIES) {
437
+ const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
438
+ console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
439
+ await new Promise(resolve => setTimeout(resolve, delay));
440
+ }
441
+ }
442
+ }
443
+
444
+ throw lastError!;
445
+ }
446
+
416
447
  export async function getGatewayUrl(accessToken: string): Promise<string> {
417
448
  const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
418
449
  return data.url;
@@ -680,7 +711,7 @@ export async function c2cUploadPartFinish(
680
711
  blockSize: number,
681
712
  md5: string,
682
713
  ): Promise<void> {
683
- await apiRequest<Record<string, unknown>>(
714
+ await partFinishWithRetry(
684
715
  accessToken, "POST", `/v2/users/${userId}/upload_part_finish`,
685
716
  { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
686
717
  );
@@ -736,7 +767,7 @@ export async function groupUploadPartFinish(
736
767
  blockSize: number,
737
768
  md5: string,
738
769
  ): Promise<void> {
739
- await apiRequest<Record<string, unknown>>(
770
+ await partFinishWithRetry(
740
771
  accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`,
741
772
  { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
742
773
  );
package/src/streaming.ts CHANGED
@@ -245,7 +245,7 @@ export class StreamingController {
245
245
  // ---- 状态机 ----
246
246
  private phase: StreamingPhase = "idle";
247
247
 
248
- // ---- 核心文本状态(仅两个) ----
248
+ // ---- 核心文本状态 ----
249
249
  /**
250
250
  * 最后一次收到的完整 normalized 全量文本。
251
251
  * - onPartialReply 每次更新(回复边界时会拼接前缀)
@@ -253,6 +253,12 @@ export class StreamingController {
253
253
  * - onIdle 校验时用于前缀匹配
254
254
  */
255
255
  private lastNormalizedFull = "";
256
+ /**
257
+ * 最后一次收到的完整原始文本(未经 normalize)。
258
+ * 仅用于回复边界检测——原始文本在 partial reply 过程中是稳定递增的,
259
+ * 不会因为 normalizeMediaTags 对未闭合标签的处理差异导致前缀不匹配。
260
+ */
261
+ private lastRawFull = "";
256
262
  /**
257
263
  * 在 lastNormalizedFull 中已经"消费"到的位置。
258
264
  * "消费"包括:已通过流式发送并终结的文本段、已处理的媒体标签。
@@ -444,12 +450,11 @@ export class StreamingController {
444
450
  return;
445
451
  }
446
452
 
447
- // ★ 回复边界检测:新文本与上次处理的内容前缀不同 新回复开始
448
- // 比较方式:新文本 normalize 后,检查是否以上次的 lastNormalizedFull 为前缀
449
- // 如果不是前缀关系(内容发生了非追加的变化),终结当前 controller,通知调用方创建新的
450
- const normalized = normalizeMediaTags(text);
451
- if (this.lastNormalizedFull && normalized.length > 0 && !normalized.startsWith(this.lastNormalizedFull)) {
452
- this.logInfo(`onPartialReply: reply boundary detected — prefix mismatch (new len=${normalized.length}, prev len=${this.lastNormalizedFull.length}), finalizing current controller`);
453
+ // ★ 回复边界检测:用原始文本做前缀比较,避免 normalizeMediaTags 对未闭合标签
454
+ // 的不稳定处理导致误判(normalize 后的文本在 partial reply 的不同阶段可能产生
455
+ // 完全不同的结果,从而使 startsWith 始终失败,导致 boundary 被反复触发)
456
+ if (this.lastRawFull && text.length > 0 && !text.startsWith(this.lastRawFull)) {
457
+ this.logInfo(`onPartialReply: reply boundary detected raw prefix mismatch (new len=${text.length}, prev len=${this.lastRawFull.length}), finalizing current controller`);
453
458
 
454
459
  // 终结当前流式会话,处理完当前内容(包括可能的未闭合媒体标签)
455
460
  this.dispatchFullyComplete = true;
@@ -465,8 +470,9 @@ export class StreamingController {
465
470
  return;
466
471
  }
467
472
 
468
- // 正常增长:使用已 normalize 的文本
469
- this.lastNormalizedFull = normalized;
473
+ // 正常增长:更新原始文本和 normalize 后的文本
474
+ this.lastRawFull = text;
475
+ this.lastNormalizedFull = normalizeMediaTags(text);;
470
476
 
471
477
  // ★ 核心:从 sentIndex 开始,处理增量文本(串行队列保证不会并发进入)
472
478
  await this.processMediaTags(this.lastNormalizedFull);