@xmanrui/dsh-im 1.3.0 → 1.5.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/README.en.md +3 -3
- package/README.md +3 -3
- package/lib/index.js +168 -158
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-api.mjs +103 -81
- package/src/channels/dingtalk/dingtalk-bridge.mjs +34 -70
- package/src/channels/feishu/bridge.mjs +38 -57
- package/src/channels/feishu/feishu-channel.mjs +52 -17
- package/src/channels/qq/markdown-reply.mjs +176 -0
- package/src/channels/qq/qq-bridge.mjs +136 -105
- package/src/channels/shared/harness-client.mjs +66 -13
- package/src/channels/shared/semantic/artifact-delivery.mjs +170 -0
- package/src/channels/shared/semantic/artifact.mjs +2 -2
- package/src/channels/shared/semantic/delivery.mjs +2 -0
- package/src/channels/shared/text-harness-bridge.mjs +24 -73
- package/src/channels/telegram/telegram-api.mjs +39 -13
- package/src/channels/telegram/telegram-runtime.mjs +10 -0
- package/src/channels/wecom/wecom-bridge.mjs +95 -98
- package/src/channels/weixin/weixin-api.mjs +140 -107
- package/src/channels/weixin/weixin-bridge.mjs +36 -74
- package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -9
|
@@ -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
|
+
}
|
|
@@ -32,16 +32,14 @@ import {
|
|
|
32
32
|
prefetchInboundFiles,
|
|
33
33
|
} from '../shared/inbound-file.mjs';
|
|
34
34
|
import {
|
|
35
|
-
materializeOutboundArtifact,
|
|
36
|
-
releaseOutboundArtifact,
|
|
37
35
|
trackOutboundArtifactProviderPromise,
|
|
38
36
|
} from '../shared/semantic/artifact.mjs';
|
|
37
|
+
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
|
39
38
|
import {
|
|
40
|
-
createArtifactFailureReceipt,
|
|
41
39
|
createDeliveryReceipt,
|
|
42
|
-
mergeDeliveryReceipts,
|
|
43
40
|
providerMessageIdsFor,
|
|
44
41
|
} from '../shared/semantic/delivery.mjs';
|
|
42
|
+
import { sendMarkdownReply } from './markdown-reply.mjs';
|
|
45
43
|
|
|
46
44
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
47
45
|
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
@@ -209,7 +207,7 @@ function qqArtifactError(error, { dispatched = false } = {}) {
|
|
|
209
207
|
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
|
|
210
208
|
else if (status === 413) wrapped.code = 'artifact-too-large';
|
|
211
209
|
else if (status === 429) wrapped.code = 'artifact-rate-limited';
|
|
212
|
-
else if (
|
|
210
|
+
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
|
|
213
211
|
wrapped.code = 'artifact-provider-rejected';
|
|
214
212
|
} else {
|
|
215
213
|
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
|
|
@@ -244,6 +242,68 @@ function waitWithSignal(promise, signal) {
|
|
|
244
242
|
});
|
|
245
243
|
}
|
|
246
244
|
|
|
245
|
+
/** Send one materialized artifact through QQ's native image message. */
|
|
246
|
+
export async function sendQqImage(
|
|
247
|
+
bot,
|
|
248
|
+
target,
|
|
249
|
+
file,
|
|
250
|
+
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
|
251
|
+
) {
|
|
252
|
+
signal?.throwIfAborted();
|
|
253
|
+
if (typeof bot?.sendImage !== 'function') {
|
|
254
|
+
const unavailable = new Error('QQ image delivery is unavailable');
|
|
255
|
+
unavailable.code = 'artifact-provider-unavailable';
|
|
256
|
+
throw unavailable;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
261
|
+
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
262
|
+
const pending = bot.sendImage(
|
|
263
|
+
target,
|
|
264
|
+
{ buffer: file.bytes },
|
|
265
|
+
{ onProgress: () => signal?.throwIfAborted() },
|
|
266
|
+
);
|
|
267
|
+
trackOutboundArtifactProviderPromise(file, pending);
|
|
268
|
+
return await waitWithSignal(pending, waitSignal);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
271
|
+
throw qqArtifactError(error, { dispatched: true });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function sendQqFile(
|
|
276
|
+
bot,
|
|
277
|
+
target,
|
|
278
|
+
file,
|
|
279
|
+
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
|
280
|
+
) {
|
|
281
|
+
signal?.throwIfAborted();
|
|
282
|
+
if (typeof bot?.sendFile !== 'function') {
|
|
283
|
+
const unavailable = new Error('QQ file delivery is unavailable');
|
|
284
|
+
unavailable.code = 'artifact-provider-unavailable';
|
|
285
|
+
throw unavailable;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
290
|
+
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
291
|
+
const pending = bot.sendFile(
|
|
292
|
+
target,
|
|
293
|
+
{ buffer: file.bytes },
|
|
294
|
+
{
|
|
295
|
+
fileName: file.fileName,
|
|
296
|
+
onProgress: () => signal?.throwIfAborted(),
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
trackOutboundArtifactProviderPromise(file, pending);
|
|
300
|
+
return await waitWithSignal(pending, waitSignal);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
303
|
+
throw qqArtifactError(error, { dispatched: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
247
307
|
function canClaimInteractionReply(message, pending) {
|
|
248
308
|
return pending.questions[pending.index]
|
|
249
309
|
&& nonEmptyString(message?.senderId) === pending.actor
|
|
@@ -490,80 +550,35 @@ export class QqHarnessBridge {
|
|
|
490
550
|
if (artifacts.length === 0) {
|
|
491
551
|
return { receipt: baseReceipt, failureNoticeVisible: false };
|
|
492
552
|
}
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
this.#signal
|
|
507
|
-
|
|
508
|
-
try {
|
|
509
|
-
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
|
510
|
-
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
|
511
|
-
const pending = this.#bot.sendFile(
|
|
512
|
-
target,
|
|
513
|
-
{ buffer: file.bytes },
|
|
514
|
-
{
|
|
515
|
-
fileName: file.fileName,
|
|
516
|
-
onProgress: () => this.#signal?.throwIfAborted(),
|
|
517
|
-
},
|
|
518
|
-
);
|
|
519
|
-
trackOutboundArtifactProviderPromise(file, pending);
|
|
520
|
-
result = await waitWithSignal(pending, waitSignal);
|
|
521
|
-
} catch (error) {
|
|
522
|
-
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
|
523
|
-
throw qqArtifactError(error, { dispatched: true });
|
|
524
|
-
}
|
|
525
|
-
this.#signal?.throwIfAborted();
|
|
526
|
-
const messageId = nonEmptyString(result?.message?.id);
|
|
527
|
-
receipts.push(createDeliveryReceipt({
|
|
528
|
-
deliveryId: file.deliveryKey,
|
|
529
|
-
presentation: 'qq-file',
|
|
530
|
-
providerMessageIds: messageId ? [messageId] : [],
|
|
531
|
-
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
|
532
|
-
}));
|
|
533
|
-
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
|
534
|
-
} catch (rawError) {
|
|
535
|
-
if (this.#signal?.aborted) throw rawError;
|
|
536
|
-
const error = qqArtifactError(rawError);
|
|
537
|
-
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
|
538
|
-
this.#logger.warn?.(
|
|
539
|
-
`[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
|
|
540
|
-
);
|
|
541
|
-
let providerMessageIds = [];
|
|
542
|
-
try {
|
|
543
|
-
const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
|
|
544
|
-
failureNoticeVisible = true;
|
|
545
|
-
providerMessageIds = providerMessageIdsFor(notice);
|
|
546
|
-
} catch (noticeError) {
|
|
547
|
-
if (this.#signal?.aborted) throw noticeError;
|
|
548
|
-
this.#logger.warn?.('[dsh-im:qq] unable to send the safe result-file failure notice');
|
|
549
|
-
}
|
|
550
|
-
receipts.push(createArtifactFailureReceipt({
|
|
551
|
-
artifactId: artifact?.artifactId ?? 'unknown',
|
|
552
|
-
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
|
553
|
-
error,
|
|
554
|
-
providerMessageIds,
|
|
555
|
-
}));
|
|
556
|
-
} finally {
|
|
557
|
-
releaseOutboundArtifact(artifact);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
return {
|
|
561
|
-
receipt: mergeDeliveryReceipts({
|
|
562
|
-
deliveryId: replyTo,
|
|
563
|
-
presentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
|
|
564
|
-
receipts,
|
|
553
|
+
const delivery = await deliverOutboundArtifacts({
|
|
554
|
+
artifacts,
|
|
555
|
+
baseReceipt,
|
|
556
|
+
deliveryId: replyTo,
|
|
557
|
+
aggregatePresentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
|
|
558
|
+
alwaysMerge: true,
|
|
559
|
+
channelKey: 'qq',
|
|
560
|
+
signal: this.#signal,
|
|
561
|
+
sendImage: (file) => sendQqImage(this.#bot, target, file, {
|
|
562
|
+
signal: this.#signal,
|
|
563
|
+
timeoutMs: this.#fileUploadTimeoutMs,
|
|
564
|
+
}),
|
|
565
|
+
sendFile: (file) => sendQqFile(this.#bot, target, file, {
|
|
566
|
+
signal: this.#signal,
|
|
567
|
+
timeoutMs: this.#fileUploadTimeoutMs,
|
|
565
568
|
}),
|
|
566
|
-
|
|
569
|
+
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
|
570
|
+
target,
|
|
571
|
+
artifactFailureText(artifact?.fileName, error),
|
|
572
|
+
),
|
|
573
|
+
logger: this.#logger,
|
|
574
|
+
});
|
|
575
|
+
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
|
576
|
+
+ delivery.artifactsSent;
|
|
577
|
+
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
|
578
|
+
+ delivery.artifactSendErrors;
|
|
579
|
+
return {
|
|
580
|
+
receipt: delivery.receipt,
|
|
581
|
+
failureNoticeVisible: delivery.failureNoticeVisible,
|
|
567
582
|
};
|
|
568
583
|
}
|
|
569
584
|
|
|
@@ -644,14 +659,17 @@ export class QqHarnessBridge {
|
|
|
644
659
|
const content = hasImages
|
|
645
660
|
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
646
661
|
: undefined;
|
|
647
|
-
|
|
662
|
+
// QQ C2C keeps one stream bubble. Progress is collected but never submitted:
|
|
663
|
+
// some clients reject replacing an already visible stream frame, which would
|
|
664
|
+
// otherwise leave a stale progress bubble plus a separate fallback answer.
|
|
648
665
|
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
|
|
649
666
|
try {
|
|
650
667
|
stream = this.#bot.openStream({ target });
|
|
651
668
|
} catch (error) {
|
|
652
|
-
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using
|
|
669
|
+
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
|
|
653
670
|
}
|
|
654
671
|
}
|
|
672
|
+
const toolErrors = [];
|
|
655
673
|
let answer;
|
|
656
674
|
let artifacts = [];
|
|
657
675
|
try {
|
|
@@ -666,14 +684,15 @@ export class QqHarnessBridge {
|
|
|
666
684
|
timeoutMs: this.#replyTimeoutMs,
|
|
667
685
|
signal: this.#signal,
|
|
668
686
|
control: { owner: this, key },
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
?
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
687
|
+
progressMode: 'all',
|
|
688
|
+
onUpdate: (update) => {
|
|
689
|
+
if (update.error) {
|
|
690
|
+
const label = nonEmptyString(update.toolName)
|
|
691
|
+
? `Tool call ${update.toolName}` : 'Tool call';
|
|
692
|
+
const text = `${label}\nError: ${update.error}`;
|
|
693
|
+
toolErrors.push(text);
|
|
694
|
+
}
|
|
695
|
+
},
|
|
677
696
|
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
678
697
|
key,
|
|
679
698
|
actor: sender,
|
|
@@ -691,31 +710,41 @@ export class QqHarnessBridge {
|
|
|
691
710
|
]);
|
|
692
711
|
}
|
|
693
712
|
this.#signal?.throwIfAborted();
|
|
694
|
-
const
|
|
713
|
+
const answerText = answerTextForDelivery(answer, artifacts);
|
|
714
|
+
const displayAnswer = toolErrors.length > 0
|
|
715
|
+
? `${answerText}\n\n---\n\n${toolErrors.join('\n\n')}`
|
|
716
|
+
: answerText;
|
|
695
717
|
let textReceipt = null;
|
|
696
718
|
let textSendError = null;
|
|
697
719
|
try {
|
|
720
|
+
let streamFinished = false;
|
|
698
721
|
if (stream) {
|
|
699
722
|
try {
|
|
700
723
|
await stream.update(displayAnswer);
|
|
701
|
-
await stream.complete();
|
|
702
724
|
streamFinished = true;
|
|
703
725
|
textReceipt = createDeliveryReceipt({
|
|
704
726
|
deliveryId: messageId,
|
|
705
727
|
presentation: 'qq-text',
|
|
706
728
|
providerMessageIds: providerMessageIdsFor(stream),
|
|
707
729
|
});
|
|
730
|
+
try {
|
|
731
|
+
await stream.complete();
|
|
732
|
+
} catch (error) {
|
|
733
|
+
this.#logger.warn?.('[dsh-im:qq] QQ stream completion failed after visible final content:', error);
|
|
734
|
+
}
|
|
708
735
|
} catch (error) {
|
|
709
736
|
stream.cancel?.();
|
|
710
|
-
this.#logger.warn?.('[dsh-im:qq] QQ stream
|
|
737
|
+
this.#logger.warn?.('[dsh-im:qq] QQ stream update failed; using markdown fallback:', error);
|
|
711
738
|
}
|
|
712
739
|
}
|
|
713
740
|
if (!streamFinished) {
|
|
714
|
-
const
|
|
741
|
+
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
|
|
742
|
+
logger: this.#logger,
|
|
743
|
+
});
|
|
715
744
|
textReceipt = createDeliveryReceipt({
|
|
716
745
|
deliveryId: messageId,
|
|
717
746
|
presentation: 'qq-text',
|
|
718
|
-
providerMessageIds: providerMessageIdsFor(
|
|
747
|
+
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
|
|
719
748
|
});
|
|
720
749
|
}
|
|
721
750
|
} catch (error) {
|
|
@@ -736,22 +765,24 @@ export class QqHarnessBridge {
|
|
|
736
765
|
return delivery.receipt;
|
|
737
766
|
} catch (error) {
|
|
738
767
|
if (error?.code === 'turn-stopped') {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
|
749
|
-
}
|
|
768
|
+
try {
|
|
769
|
+
stream?.cancel?.();
|
|
770
|
+
} catch (streamError) {
|
|
771
|
+
this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
await this.#bot.sendText(target, '已停止。');
|
|
775
|
+
} catch (sendError) {
|
|
776
|
+
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
|
750
777
|
}
|
|
751
778
|
await this.#state.markSeen(messageId);
|
|
752
779
|
return;
|
|
753
780
|
}
|
|
754
|
-
|
|
781
|
+
try {
|
|
782
|
+
stream?.cancel?.();
|
|
783
|
+
} catch (streamError) {
|
|
784
|
+
this.#logger.warn?.('[dsh-im:qq] unable to cancel a failed QQ stream:', streamError);
|
|
785
|
+
}
|
|
755
786
|
if (this.#signal?.aborted) return;
|
|
756
787
|
this.#status.lastError = error?.message ?? String(error);
|
|
757
788
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
+
pushUpdate({ type: 'text', text });
|
|
407
436
|
}
|
|
408
437
|
continue;
|
|
409
438
|
}
|
|
410
439
|
|
|
411
440
|
if (event.type === 'tool/call') {
|
|
412
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1193
|
-
if (
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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;
|