@xmanrui/dsh-im 1.3.0 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -0,0 +1,176 @@
1
+ // QQ markdown 回复投递:长文尽量按结构边界切分,以 msg_type=2 发送,
2
+ // 平台拒绝 markdown 时逐条回退纯文本。
3
+
4
+ const DEFAULT_CHUNK_LIMIT = 4_500;
5
+ const CODE_FENCE_OPEN = /^```/;
6
+ const GFM_TABLE_LINE = /^\|.+\|$/;
7
+ const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
8
+ const PARTIAL_REPLY_NOTICE = '回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。';
9
+
10
+ function safeSliceIndex(value, limit) {
11
+ let index = Math.min(limit, value.length);
12
+ const before = value.charCodeAt(index - 1);
13
+ const after = value.charCodeAt(index);
14
+ if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
15
+ index -= 1;
16
+ }
17
+ return Math.max(1, index);
18
+ }
19
+
20
+ /**
21
+ * 按换行边界切分 Markdown 文本:
22
+ * - 不在代码块中间断开;
23
+ * - 不在 GFM 表格中间断开;
24
+ * - 超长行在 limit 处硬切,避免单行超限无法投递。
25
+ */
26
+ export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
27
+ const value = typeof text === 'string' ? text : '';
28
+ const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
29
+ if (value.length <= bound) return value ? [value] : [];
30
+
31
+ const lines = value.split('\n');
32
+ const chunks = [];
33
+ let current = '';
34
+ let inCodeBlock = false;
35
+ let tableBuffer = [];
36
+
37
+ const appendBlock = (block) => {
38
+ if (block.length <= bound) {
39
+ if (!current) {
40
+ current = block;
41
+ return;
42
+ }
43
+ const candidate = `${current}\n${block}`;
44
+ if (candidate.length > bound) {
45
+ chunks.push(current);
46
+ current = block;
47
+ } else {
48
+ current = candidate;
49
+ }
50
+ return;
51
+ }
52
+ // 超大块:收束当前块后按 bound 硬切,保证每块可投递。
53
+ if (current) {
54
+ chunks.push(current);
55
+ current = '';
56
+ }
57
+ let remaining = block;
58
+ while (remaining.length > bound) {
59
+ const index = safeSliceIndex(remaining, bound);
60
+ chunks.push(remaining.slice(0, index));
61
+ remaining = remaining.slice(index);
62
+ }
63
+ current = remaining;
64
+ };
65
+
66
+ const flushTable = () => {
67
+ if (tableBuffer.length === 0) return;
68
+ const block = tableBuffer.join('\n');
69
+ tableBuffer = [];
70
+ appendBlock(block);
71
+ };
72
+
73
+ const appendLine = (line) => {
74
+ let remaining = line;
75
+ // 超长行先硬切,保证每块不超过 bound。
76
+ while (remaining.length > bound) {
77
+ if (current) {
78
+ chunks.push(current);
79
+ current = '';
80
+ }
81
+ const index = safeSliceIndex(remaining, bound);
82
+ chunks.push(remaining.slice(0, index));
83
+ remaining = remaining.slice(index);
84
+ }
85
+ appendBlock(remaining);
86
+ };
87
+
88
+ for (const line of lines) {
89
+ if (CODE_FENCE_OPEN.test(line)) {
90
+ flushTable();
91
+ if (!inCodeBlock && current) {
92
+ // 代码块开启:先收束当前块,让整个代码块从新块开始。
93
+ chunks.push(current);
94
+ current = '';
95
+ }
96
+ inCodeBlock = !inCodeBlock;
97
+ appendLine(line);
98
+ continue;
99
+ }
100
+ if (inCodeBlock) {
101
+ appendLine(line);
102
+ continue;
103
+ }
104
+ if (GFM_TABLE_LINE.test(line)) {
105
+ tableBuffer.push(line);
106
+ continue;
107
+ }
108
+ flushTable();
109
+ appendLine(line);
110
+ }
111
+
112
+ flushTable();
113
+ if (current) chunks.push(current);
114
+ return chunks;
115
+ }
116
+
117
+ function nextMsgSeq() {
118
+ // 与 SDK getNextMsgSeq 相同的随机策略:被动回复同 msg_id 的多条消息
119
+ // 各自带不同 msg_seq,避免平台去重(错误码 40054005)。
120
+ const timePart = Date.now() % 100_000_000;
121
+ const random = Math.floor(Math.random() * 65_536);
122
+ return (timePart ^ random) % 65_536;
123
+ }
124
+
125
+ /**
126
+ * 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
127
+ * 返回每条消息的平台响应,供调用方提取 provider message ids。
128
+ */
129
+ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
130
+ const chunks = chunkMarkdownText(text);
131
+ const results = [];
132
+ const passiveLimit = target?.msgId ? PASSIVE_REPLY_LIMIT[target.scope] : null;
133
+ const overflow = passiveLimit !== null && chunks.length > passiveLimit;
134
+ const passiveContentCount = overflow ? passiveLimit - 1 : chunks.length;
135
+ const proactiveTarget = target?.msgId
136
+ ? { scope: target.scope, targetId: target.targetId }
137
+ : target;
138
+ let partialNoticeSent = false;
139
+
140
+ const sendPartialNotice = async () => {
141
+ if (partialNoticeSent || !target?.msgId) return;
142
+ partialNoticeSent = true;
143
+ try {
144
+ results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE));
145
+ } catch (error) {
146
+ logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
147
+ }
148
+ };
149
+
150
+ for (const [index, chunk] of chunks.entries()) {
151
+ const deliveryTarget = overflow && index >= passiveContentCount
152
+ ? proactiveTarget
153
+ : target;
154
+ if (typeof bot?.send === 'function') {
155
+ try {
156
+ results.push(await bot.send({
157
+ target: deliveryTarget,
158
+ msgType: 2,
159
+ markdown: { content: chunk },
160
+ extra: { msg_seq: nextMsgSeq() },
161
+ }));
162
+ continue;
163
+ } catch (error) {
164
+ logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
165
+ }
166
+ }
167
+ try {
168
+ results.push(await bot.sendText(deliveryTarget, chunk));
169
+ } catch (error) {
170
+ if (results.length === 0) throw error;
171
+ await sendPartialNotice();
172
+ break;
173
+ }
174
+ }
175
+ return results;
176
+ }
@@ -42,6 +42,7 @@ import {
42
42
  mergeDeliveryReceipts,
43
43
  providerMessageIdsFor,
44
44
  } from '../shared/semantic/delivery.mjs';
45
+ import { sendMarkdownReply } from './markdown-reply.mjs';
45
46
 
46
47
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
47
48
  const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
@@ -644,14 +645,17 @@ export class QqHarnessBridge {
644
645
  const content = hasImages
645
646
  ? await promptContentForMessage(promptMessage, { signal: this.#signal })
646
647
  : undefined;
647
- let streamFinished = false;
648
+ // QQ C2C keeps one stream bubble. Progress is collected but never submitted:
649
+ // some clients reject replacing an already visible stream frame, which would
650
+ // otherwise leave a stale progress bubble plus a separate fallback answer.
648
651
  if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
649
652
  try {
650
653
  stream = this.#bot.openStream({ target });
651
654
  } catch (error) {
652
- this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
655
+ this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
653
656
  }
654
657
  }
658
+ const toolErrors = [];
655
659
  let answer;
656
660
  let artifacts = [];
657
661
  try {
@@ -666,14 +670,15 @@ export class QqHarnessBridge {
666
670
  timeoutMs: this.#replyTimeoutMs,
667
671
  signal: this.#signal,
668
672
  control: { owner: this, key },
669
- onUpdate: stream ? async (update) => {
670
- const progress = update.type === 'text'
671
- ? update.text
672
- : update.type === 'tool'
673
- ? `正在使用${update.name}…`
674
- : update.text;
675
- if (progress) await stream.update(progress);
676
- } : undefined,
673
+ progressMode: 'all',
674
+ onUpdate: (update) => {
675
+ if (update.error) {
676
+ const label = nonEmptyString(update.toolName)
677
+ ? `Tool call ${update.toolName}` : 'Tool call';
678
+ const text = `${label}\nError: ${update.error}`;
679
+ toolErrors.push(text);
680
+ }
681
+ },
677
682
  onInteraction: (interaction) => this.#handleInteraction(interaction, {
678
683
  key,
679
684
  actor: sender,
@@ -691,31 +696,41 @@ export class QqHarnessBridge {
691
696
  ]);
692
697
  }
693
698
  this.#signal?.throwIfAborted();
694
- const displayAnswer = answerTextForDelivery(answer, artifacts);
699
+ const answerText = answerTextForDelivery(answer, artifacts);
700
+ const displayAnswer = toolErrors.length > 0
701
+ ? `${answerText}\n\n---\n\n${toolErrors.join('\n\n')}`
702
+ : answerText;
695
703
  let textReceipt = null;
696
704
  let textSendError = null;
697
705
  try {
706
+ let streamFinished = false;
698
707
  if (stream) {
699
708
  try {
700
709
  await stream.update(displayAnswer);
701
- await stream.complete();
702
710
  streamFinished = true;
703
711
  textReceipt = createDeliveryReceipt({
704
712
  deliveryId: messageId,
705
713
  presentation: 'qq-text',
706
714
  providerMessageIds: providerMessageIdsFor(stream),
707
715
  });
716
+ try {
717
+ await stream.complete();
718
+ } catch (error) {
719
+ this.#logger.warn?.('[dsh-im:qq] QQ stream completion failed after visible final content:', error);
720
+ }
708
721
  } catch (error) {
709
722
  stream.cancel?.();
710
- this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', error);
723
+ this.#logger.warn?.('[dsh-im:qq] QQ stream update failed; using markdown fallback:', error);
711
724
  }
712
725
  }
713
726
  if (!streamFinished) {
714
- const sent = await this.#bot.sendText(target, displayAnswer);
727
+ const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
728
+ logger: this.#logger,
729
+ });
715
730
  textReceipt = createDeliveryReceipt({
716
731
  deliveryId: messageId,
717
732
  presentation: 'qq-text',
718
- providerMessageIds: providerMessageIdsFor(sent),
733
+ providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
719
734
  });
720
735
  }
721
736
  } catch (error) {
@@ -736,22 +751,24 @@ export class QqHarnessBridge {
736
751
  return delivery.receipt;
737
752
  } catch (error) {
738
753
  if (error?.code === 'turn-stopped') {
739
- if (stream) {
740
- try {
741
- await stream.cancel?.();
742
- } catch (streamError) {
743
- this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
744
- }
745
- try {
746
- await this.#bot.sendText(target, '已停止。');
747
- } catch (sendError) {
748
- this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
749
- }
754
+ try {
755
+ stream?.cancel?.();
756
+ } catch (streamError) {
757
+ this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
758
+ }
759
+ try {
760
+ await this.#bot.sendText(target, '已停止。');
761
+ } catch (sendError) {
762
+ this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
750
763
  }
751
764
  await this.#state.markSeen(messageId);
752
765
  return;
753
766
  }
754
- stream?.cancel?.();
767
+ try {
768
+ stream?.cancel?.();
769
+ } catch (streamError) {
770
+ this.#logger.warn?.('[dsh-im:qq] unable to cancel a failed QQ stream:', streamError);
771
+ }
755
772
  if (this.#signal?.aborted) return;
756
773
  this.#status.lastError = error?.message ?? String(error);
757
774
  this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
@@ -255,6 +255,21 @@ function assistantMessageText(event) {
255
255
  .trim();
256
256
  }
257
257
 
258
+ function nonEmptyText(value) {
259
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
260
+ }
261
+
262
+ /** Flatten a tool/result error payload into a displayable one-line reason. */
263
+ function toolResultErrorText(error) {
264
+ if (!error || typeof error !== 'object') return null;
265
+ const message = nonEmptyText(error.message);
266
+ if (message) return message;
267
+ const name = nonEmptyText(error.name);
268
+ const code = nonEmptyText(error.code);
269
+ if (name || code) return [name ?? 'Error', code].filter(Boolean).join(': ');
270
+ return null;
271
+ }
272
+
258
273
  function consumeInteractionOwnership(ownership, entries) {
259
274
  const ordered = [...entries]
260
275
  .map((entry) => entry?.event ?? entry)
@@ -325,6 +340,8 @@ export class HarnessReplyTracker {
325
340
  #latestText = '';
326
341
  #finished = false;
327
342
  #reason = null;
343
+ #toolNames = new Map();
344
+ #lastToolName = null;
328
345
 
329
346
  constructor({ promptRpcId, afterSeq = -1 }) {
330
347
  this.#promptRpcId = promptRpcId;
@@ -351,8 +368,20 @@ export class HarnessReplyTracker {
351
368
  return this.#targetTurn;
352
369
  }
353
370
 
354
- consume(entries) {
355
- let update = null;
371
+ consumeAll(entries) {
372
+ const updates = [];
373
+ // 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
374
+ // 让消费方能按顺序看到每个工具调用与结果。
375
+ const pushUpdate = (update) => {
376
+ if (update.type === 'text' && updates.length > 0) {
377
+ const last = updates[updates.length - 1];
378
+ if (last.type === 'text') {
379
+ updates[updates.length - 1] = update;
380
+ return;
381
+ }
382
+ }
383
+ updates.push(update);
384
+ };
356
385
  const ordered = [...entries]
357
386
  .map((entry) => entry?.event ?? entry)
358
387
  .filter(Boolean)
@@ -394,7 +423,7 @@ export class HarnessReplyTracker {
394
423
  .trim();
395
424
  if (text && text !== this.#latestText) {
396
425
  this.#latestText = text;
397
- update = { type: 'text', text };
426
+ pushUpdate({ type: 'text', text });
398
427
  }
399
428
  continue;
400
429
  }
@@ -403,18 +432,38 @@ export class HarnessReplyTracker {
403
432
  const text = assistantMessageText(event);
404
433
  if (text && text !== this.#latestText) {
405
434
  this.#latestText = text;
406
- update = { type: 'text', text };
435
+ pushUpdate({ type: 'text', text });
407
436
  }
408
437
  continue;
409
438
  }
410
439
 
411
440
  if (event.type === 'tool/call') {
412
- update = { type: 'tool', name: event.data?.name ?? '工具' };
441
+ const name = nonEmptyText(event.data?.name) ?? '工具';
442
+ const callId = nonEmptyText(event.data?.callId)
443
+ ?? nonEmptyText(event.data?.subCallId);
444
+ if (callId) this.#toolNames.set(callId, name);
445
+ this.#lastToolName = name;
446
+ pushUpdate({ type: 'tool', name, ...(callId ? { callId } : {}) });
413
447
  } else if (event.type === 'tool/result') {
414
- update = { type: 'status', text: '正在整理结果…' };
448
+ const callId = nonEmptyText(event.data?.message?.source?.callId)
449
+ ?? nonEmptyText(event.data?.callId)
450
+ ?? nonEmptyText(event.data?.subCallId);
451
+ const toolName = (callId ? this.#toolNames.get(callId) : null)
452
+ ?? this.#lastToolName;
453
+ const error = toolResultErrorText(event.data?.error);
454
+ pushUpdate({
455
+ type: 'status',
456
+ text: '正在整理结果…',
457
+ ...(toolName ? { toolName } : {}),
458
+ ...(error ? { error } : {}),
459
+ });
415
460
  }
416
461
  }
417
- return update;
462
+ return updates;
463
+ }
464
+
465
+ consume(entries) {
466
+ return this.consumeAll(entries).at(-1) ?? null;
418
467
  }
419
468
  }
420
469
 
@@ -1036,6 +1085,7 @@ export class HarnessClient {
1036
1085
  const timeoutMs = options.timeoutMs ?? 600_000;
1037
1086
  const signal = options.signal;
1038
1087
  const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
1088
+ const progressMode = options.progressMode === 'all' ? 'all' : 'latest';
1039
1089
  const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
1040
1090
  const onInteraction = typeof options.onInteraction === 'function'
1041
1091
  ? options.onInteraction
@@ -1189,12 +1239,15 @@ export class HarnessClient {
1189
1239
  this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
1190
1240
  if (!wasActive && ownership.active) ownership.reconnect?.();
1191
1241
  }
1192
- const update = tracker.consume(history.events ?? []);
1193
- if (update && onUpdate) {
1194
- try {
1195
- await onUpdate(update);
1196
- } catch (error) {
1197
- console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
1242
+ const updates = tracker.consumeAll(history.events ?? []);
1243
+ if (onUpdate) {
1244
+ const visibleUpdates = progressMode === 'all' ? updates : updates.slice(-1);
1245
+ for (const update of visibleUpdates) {
1246
+ try {
1247
+ await onUpdate(update);
1248
+ } catch (error) {
1249
+ console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
1250
+ }
1198
1251
  }
1199
1252
  }
1200
1253
  if (!tracker.finished) continue;