@xmanrui/dsh-im 2.2.1 → 2.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/README.en.md +12 -3
- package/README.md +12 -3
- package/lib/client.js +95 -52
- package/lib/index.js +220 -191
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/index.js +37 -27
- package/plugin-src/client/channels/feishu/styles.js +26 -1
- package/plugin-src/client/i18n.js +27 -25
- package/src/channels/dingtalk/dingtalk-bridge.mjs +96 -3
- package/src/channels/discord/discord-runtime.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +176 -31
- package/src/channels/feishu/feishu-cards.mjs +18 -6
- package/src/channels/feishu/feishu-runtime.mjs +0 -1
- package/src/channels/feishu/message-utils.mjs +1 -1
- package/src/channels/feishu/multi-bot-controller.mjs +1 -17
- package/src/channels/feishu/repair-manager.mjs +7 -2
- package/src/channels/qq/qq-bridge.mjs +99 -8
- package/src/channels/shared/batch-input.mjs +209 -0
- package/src/channels/shared/control-command.mjs +11 -1
- package/src/channels/shared/i18n-en/feishu.mjs +28 -25
- package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +49 -0
- package/src/channels/shared/i18n-en/telegram.mjs +1 -0
- package/src/channels/shared/text-harness-bridge.mjs +112 -2
- package/src/channels/slack/slack-runtime.mjs +1 -0
- package/src/channels/telegram/telegram-runtime.mjs +5 -0
- package/src/channels/wecom/wecom-bridge.mjs +102 -4
- package/src/channels/weixin/weixin-bridge.mjs +101 -3
- package/src/channels/weixin/weixin-runtime.mjs +18 -1
- package/src/channels/whatsapp/whatsapp-runtime.mjs +2 -0
|
@@ -96,4 +96,53 @@ export default {
|
|
|
96
96
|
|
|
97
97
|
// agent-preset.mjs
|
|
98
98
|
'Agent Preset 无效。': 'Invalid Agent Preset.',
|
|
99
|
+
|
|
100
|
+
// batch-input.mjs
|
|
101
|
+
'/batch 开始批量输入(仅私聊,最多 10 条文字)':
|
|
102
|
+
'/batch Start batch input (direct messages only, up to 10 text messages)',
|
|
103
|
+
'/send 提交当前批次': '/send Submit the current batch',
|
|
104
|
+
'/cancel 取消当前批次': '/cancel Cancel the current batch',
|
|
105
|
+
'开始批量输入(仅私聊)': 'Start batch input (direct messages only)',
|
|
106
|
+
'提交当前批次': 'Submit the current batch',
|
|
107
|
+
'取消当前批次': 'Cancel the current batch',
|
|
108
|
+
'当前已处于批量输入模式,已收集 {count}/{limit} 条。\n请发送 /send 提交或 /cancel 取消。':
|
|
109
|
+
'Batch input is already active with {count}/{limit} messages collected.\nSend /send to submit or /cancel to cancel.',
|
|
110
|
+
'当前已处于批量输入模式,已收集 {count}/{limit} 条。\n完成后发送 /send,取消请发送 /cancel。':
|
|
111
|
+
'Batch input is already active with {count}/{limit} messages collected.\nSend /send when finished or /cancel to cancel.',
|
|
112
|
+
'[消息 {index}]': '[Message {index}]',
|
|
113
|
+
'以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。':
|
|
114
|
+
'The user sent the following messages in batch input mode. Process them in order as one input.',
|
|
115
|
+
'批量输入模式仅支持私聊,请在与机器人的私聊中使用。':
|
|
116
|
+
'Batch input is available only in direct messages. Please use it in a direct chat with the bot.',
|
|
117
|
+
'当前聊天有正在运行的任务、待回答问题或待审批请求。\n请先完成当前交互或发送 /stop,再使用 /batch。':
|
|
118
|
+
'This chat has a running task, unanswered question, or pending approval.\nFinish the current interaction or send /stop before using /batch.',
|
|
119
|
+
'用法:/{command}(不带参数)': 'Usage: /{command} (without arguments)',
|
|
120
|
+
'批量输入命令仅支持纯文字,请移除图片或文件后重试。':
|
|
121
|
+
'Batch input commands support text only. Remove the image or file and try again.',
|
|
122
|
+
'当前没有待提交的批量内容,请先发送 /batch。':
|
|
123
|
+
'There is no batch to submit. Send /batch first.',
|
|
124
|
+
'当前没有正在进行的批量输入。': 'There is no active batch input.',
|
|
125
|
+
'已进入批量输入模式,最多可发送 {limit} 条文字。\n完成后发送 /send,取消请发送 /cancel。':
|
|
126
|
+
'Batch input started. You can send up to {limit} text messages.\nSend /send when finished or /cancel to cancel.',
|
|
127
|
+
'批量输入模式目前仅支持文字,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
|
|
128
|
+
'Batch input currently supports text only, so this message was not collected.\nContinue with text, or use /send or /cancel.',
|
|
129
|
+
'当前批次正在提交,请勿重复发送 /send。':
|
|
130
|
+
'The current batch is being submitted. Do not send /send again.',
|
|
131
|
+
'批量内容已经提交,无法取消。\n如需停止当前任务,请发送 /stop。':
|
|
132
|
+
'The batch has already been submitted and cannot be cancelled.\nSend /stop if you need to stop the current task.',
|
|
133
|
+
'当前批次正在提交,请等待处理完成后再开启新批次。':
|
|
134
|
+
'The current batch is being submitted. Wait for it to finish before starting another batch.',
|
|
135
|
+
'已取消批量输入。': 'Batch input cancelled.',
|
|
136
|
+
'已取消批量输入,共丢弃 {count} 条消息。':
|
|
137
|
+
'Batch input cancelled; {count} messages were discarded.',
|
|
138
|
+
'当前批次还没有内容,请先发送文字,或使用 /cancel 取消。':
|
|
139
|
+
'The current batch is empty. Send some text first or use /cancel.',
|
|
140
|
+
'当前正在批量输入,请先发送 /send 提交或 /cancel 取消。':
|
|
141
|
+
'Batch input is active. Send /send to submit or /cancel to cancel first.',
|
|
142
|
+
'当前批次已满,这条消息未收录。\n请先发送 /send 提交或 /cancel 取消,然后重新发送这条消息。':
|
|
143
|
+
'The current batch is full, so this message was not collected.\nSend /send or /cancel first, then resend this message.',
|
|
144
|
+
'已收集 {count}/{limit} 条,当前批次已满,请发送 /send 提交或 /cancel 取消。':
|
|
145
|
+
'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
|
|
146
|
+
'批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
|
|
147
|
+
'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
|
|
99
148
|
};
|
|
@@ -13,6 +13,7 @@ export default {
|
|
|
13
13
|
'停止当前任务': 'Stop the current task',
|
|
14
14
|
'纠偏当前任务': 'Steer the current task',
|
|
15
15
|
'检查连接状态': 'Check connection status',
|
|
16
|
+
'查看插件版本': 'Show plugin version',
|
|
16
17
|
'显示帮助': 'Show help',
|
|
17
18
|
'该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。':
|
|
18
19
|
'This Telegram bot already has a Webhook configured. Remove the Webhook from the original service and try again.',
|
|
@@ -19,6 +19,12 @@ import {
|
|
|
19
19
|
} from './preset-command.mjs';
|
|
20
20
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
21
21
|
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
|
22
|
+
import {
|
|
23
|
+
BatchInputManager,
|
|
24
|
+
batchInputBusyMessage,
|
|
25
|
+
batchInputGroupUnsupportedMessage,
|
|
26
|
+
isBatchInputCommand,
|
|
27
|
+
} from './batch-input.mjs';
|
|
22
28
|
import {
|
|
23
29
|
hasInboundImages,
|
|
24
30
|
imagePromptUserMessage,
|
|
@@ -119,6 +125,7 @@ export class TextHarnessBridge {
|
|
|
119
125
|
#approvalTasks = new Set();
|
|
120
126
|
#commandTasks = new Set();
|
|
121
127
|
#approvals;
|
|
128
|
+
#batches = new BatchInputManager();
|
|
122
129
|
|
|
123
130
|
constructor({
|
|
124
131
|
descriptor,
|
|
@@ -173,7 +180,54 @@ export class TextHarnessBridge {
|
|
|
173
180
|
const key = `${kind}:${conversationId}`;
|
|
174
181
|
const pending = this.#pendingInteractions.get(key);
|
|
175
182
|
const text = cleanText(normalized.content);
|
|
176
|
-
const
|
|
183
|
+
const batchCommand = isBatchInputCommand(text);
|
|
184
|
+
if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
|
|
185
|
+
return this.#finishLocalMessage(
|
|
186
|
+
normalized,
|
|
187
|
+
messageId,
|
|
188
|
+
batchInputGroupUnsupportedMessage(),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (batchCommand && normalized.kind === 'direct') {
|
|
192
|
+
const exactBatch = /^\/batch$/iu.test(text);
|
|
193
|
+
if (exactBatch && (
|
|
194
|
+
this.#queues.has(key)
|
|
195
|
+
|| Boolean(pending)
|
|
196
|
+
|| this.#approvals.hasPending(key)
|
|
197
|
+
)) {
|
|
198
|
+
return this.#finishLocalMessage(normalized, messageId, batchInputBusyMessage());
|
|
199
|
+
}
|
|
200
|
+
const batch = this.#batches.handle(key, text, {
|
|
201
|
+
plainText: Boolean(text)
|
|
202
|
+
&& normalized.plainText !== false
|
|
203
|
+
&& !hasInboundImages(normalized)
|
|
204
|
+
&& !hasInboundFiles(normalized),
|
|
205
|
+
});
|
|
206
|
+
if (batch.handled) {
|
|
207
|
+
if (batch.kind === 'submit') {
|
|
208
|
+
return this.#enqueueMessage({
|
|
209
|
+
...normalized,
|
|
210
|
+
content: batch.prompt,
|
|
211
|
+
batchSubmission: { token: batch.token },
|
|
212
|
+
}, messageId, senderId, key);
|
|
213
|
+
}
|
|
214
|
+
return this.#finishLocalMessage(normalized, messageId, batch.message);
|
|
215
|
+
}
|
|
216
|
+
} else if (normalized.kind === 'direct'
|
|
217
|
+
&& this.#batches.status(key).phase === 'collecting') {
|
|
218
|
+
const batch = this.#batches.handle(key, text, {
|
|
219
|
+
plainText: Boolean(text)
|
|
220
|
+
&& normalized.plainText !== false
|
|
221
|
+
&& !hasInboundImages(normalized)
|
|
222
|
+
&& !hasInboundFiles(normalized),
|
|
223
|
+
});
|
|
224
|
+
if (batch.handled) {
|
|
225
|
+
return this.#finishLocalMessage(normalized, messageId, batch.message);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const collectingBatch = normalized.kind === 'direct'
|
|
229
|
+
&& this.#batches.status(key).phase === 'collecting';
|
|
230
|
+
const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text)
|
|
177
231
|
? runControlCommand
|
|
178
232
|
: (isModelCommand(text)
|
|
179
233
|
? runModelCommand
|
|
@@ -254,6 +308,30 @@ export class TextHarnessBridge {
|
|
|
254
308
|
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
|
255
309
|
}
|
|
256
310
|
|
|
311
|
+
#finishLocalMessage(message, messageId, reply) {
|
|
312
|
+
let task;
|
|
313
|
+
task = (async () => {
|
|
314
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
315
|
+
await this.#state.markSeen(messageId);
|
|
316
|
+
this.#status.messagesReceived += 1;
|
|
317
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
318
|
+
if (reply) await this.#bot.sendText(message.replyTarget, reply);
|
|
319
|
+
this.#status.lastError = null;
|
|
320
|
+
})().catch(async (error) => {
|
|
321
|
+
if (this.#signal?.aborted) return;
|
|
322
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
323
|
+
this.#logger.error?.(
|
|
324
|
+
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
|
|
325
|
+
error,
|
|
326
|
+
);
|
|
327
|
+
}).finally(() => {
|
|
328
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
329
|
+
this.#commandTasks.delete(task);
|
|
330
|
+
});
|
|
331
|
+
this.#commandTasks.add(task);
|
|
332
|
+
return task;
|
|
333
|
+
}
|
|
334
|
+
|
|
257
335
|
#enqueueMessage(message, messageId, senderId, key, {
|
|
258
336
|
releaseMessageId = true,
|
|
259
337
|
alreadyRecorded = false,
|
|
@@ -373,6 +451,7 @@ export class TextHarnessBridge {
|
|
|
373
451
|
|
|
374
452
|
const target = message.replyTarget;
|
|
375
453
|
const text = cleanText(message.content);
|
|
454
|
+
const batchSubmission = message.batchSubmission;
|
|
376
455
|
let stream = null;
|
|
377
456
|
let semanticStream = false;
|
|
378
457
|
try {
|
|
@@ -411,7 +490,11 @@ export class TextHarnessBridge {
|
|
|
411
490
|
t('/preset --default 跟随 Host 默认'),
|
|
412
491
|
t('/stop 停止当前任务'),
|
|
413
492
|
t('/steer 补充指令 纠偏当前任务'),
|
|
493
|
+
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
|
494
|
+
t('/send 提交当前批次'),
|
|
495
|
+
t('/cancel 取消当前批次'),
|
|
414
496
|
t('/status 检查连接状态'),
|
|
497
|
+
t('/version 查看插件版本'),
|
|
415
498
|
t('/help 显示本帮助'),
|
|
416
499
|
].join('\n'));
|
|
417
500
|
return;
|
|
@@ -508,6 +591,9 @@ export class TextHarnessBridge {
|
|
|
508
591
|
files: message.files,
|
|
509
592
|
},
|
|
510
593
|
});
|
|
594
|
+
if (batchSubmission) {
|
|
595
|
+
this.#batches.complete(conversationKey, batchSubmission.token);
|
|
596
|
+
}
|
|
511
597
|
const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
|
|
512
598
|
const visibleAnswer = fileOnlyCompletion
|
|
513
599
|
? t(FILE_ONLY_COMPLETION_TEXT)
|
|
@@ -579,7 +665,14 @@ export class TextHarnessBridge {
|
|
|
579
665
|
}
|
|
580
666
|
return delivery.receipt;
|
|
581
667
|
} catch (error) {
|
|
582
|
-
|
|
668
|
+
const turnStopped = error?.code === 'turn-stopped';
|
|
669
|
+
if (batchSubmission && turnStopped) {
|
|
670
|
+
this.#batches.complete(conversationKey, batchSubmission.token);
|
|
671
|
+
}
|
|
672
|
+
const failedBatch = batchSubmission && !turnStopped
|
|
673
|
+
? this.#batches.fail(conversationKey, batchSubmission.token)
|
|
674
|
+
: null;
|
|
675
|
+
if (turnStopped) {
|
|
583
676
|
if (stream) {
|
|
584
677
|
try {
|
|
585
678
|
await stream.finish(t('已停止。'));
|
|
@@ -607,6 +700,23 @@ export class TextHarnessBridge {
|
|
|
607
700
|
return false;
|
|
608
701
|
}
|
|
609
702
|
};
|
|
703
|
+
if (failedBatch?.retained) {
|
|
704
|
+
this.#logger.error?.(
|
|
705
|
+
`[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
|
|
706
|
+
error,
|
|
707
|
+
);
|
|
708
|
+
if (await presentStreamFailure(failedBatch.message)) return;
|
|
709
|
+
stream?.cancel?.();
|
|
710
|
+
try {
|
|
711
|
+
await this.#bot.sendText(target, failedBatch.message);
|
|
712
|
+
} catch (sendError) {
|
|
713
|
+
this.#logger.error?.(
|
|
714
|
+
`[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
|
|
715
|
+
sendError,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
610
720
|
const imageErrorMessage = imagePromptUserMessage(error);
|
|
611
721
|
if (imageErrorMessage) {
|
|
612
722
|
if (await presentStreamFailure(imageErrorMessage)) return;
|
|
@@ -119,6 +119,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|
|
119
119
|
kind: direct ? 'direct' : 'group',
|
|
120
120
|
conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
|
|
121
121
|
content: stripBotMention(event.text ?? '', botUserId),
|
|
122
|
+
plainText: !Array.isArray(event.files) || event.files.length === 0,
|
|
122
123
|
images: Array.isArray(event.files)
|
|
123
124
|
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
|
|
124
125
|
: [],
|
|
@@ -28,7 +28,11 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze([
|
|
|
28
28
|
{ command: 'preset', description: '查看或设置新会话 Agent Preset' },
|
|
29
29
|
{ command: 'stop', description: '停止当前任务' },
|
|
30
30
|
{ command: 'steer', description: '纠偏当前任务' },
|
|
31
|
+
{ command: 'batch', description: '开始批量输入(仅私聊)' },
|
|
32
|
+
{ command: 'send', description: '提交当前批次' },
|
|
33
|
+
{ command: 'cancel', description: '取消当前批次' },
|
|
31
34
|
{ command: 'status', description: '检查连接状态' },
|
|
35
|
+
{ command: 'version', description: '查看插件版本' },
|
|
32
36
|
{ command: 'help', description: '显示帮助' },
|
|
33
37
|
]);
|
|
34
38
|
|
|
@@ -160,6 +164,7 @@ export function normalizeTelegramUpdate(update, {
|
|
|
160
164
|
conversationId: messageThreadId === undefined
|
|
161
165
|
? String(chatId) : `${chatId}:${messageThreadId}`,
|
|
162
166
|
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
|
167
|
+
plainText: typeof message.text === 'string',
|
|
163
168
|
images: image ? [image] : [],
|
|
164
169
|
files: file ? [file] : [],
|
|
165
170
|
addressed,
|
|
@@ -5,6 +5,12 @@ import {
|
|
|
5
5
|
validHarnessQuestion,
|
|
6
6
|
} from '../shared/harness-question.mjs';
|
|
7
7
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
8
|
+
import {
|
|
9
|
+
BatchInputManager,
|
|
10
|
+
batchInputBusyMessage,
|
|
11
|
+
batchInputGroupUnsupportedMessage,
|
|
12
|
+
isBatchInputCommand,
|
|
13
|
+
} from '../shared/batch-input.mjs';
|
|
8
14
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
9
15
|
import {
|
|
10
16
|
isControlCommand,
|
|
@@ -63,7 +69,11 @@ function helpText() {
|
|
|
63
69
|
t('/preset --default 跟随 Host 默认'),
|
|
64
70
|
t('/stop 停止当前任务'),
|
|
65
71
|
t('/steer 补充指令 纠偏当前任务'),
|
|
72
|
+
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
|
73
|
+
t('/send 提交当前批次'),
|
|
74
|
+
t('/cancel 取消当前批次'),
|
|
66
75
|
t('/status 检查连接状态'),
|
|
76
|
+
t('/version 查看插件版本'),
|
|
67
77
|
t('/help 显示本帮助'),
|
|
68
78
|
].join('\n');
|
|
69
79
|
}
|
|
@@ -254,6 +264,10 @@ function interactionReplyText(frame) {
|
|
|
254
264
|
return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
|
|
255
265
|
}
|
|
256
266
|
|
|
267
|
+
function isNativeWecomText(frame) {
|
|
268
|
+
return bodyOf(frame).msgtype === 'text' && Boolean(nonEmptyString(messageText(frame)));
|
|
269
|
+
}
|
|
270
|
+
|
|
257
271
|
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
|
|
258
272
|
const source = String(text ?? '').trim();
|
|
259
273
|
if (!source) return [];
|
|
@@ -466,6 +480,7 @@ export class WecomHarnessBridge {
|
|
|
466
480
|
#approvalTasks = new Set();
|
|
467
481
|
#commandTasks = new Set();
|
|
468
482
|
#approvals;
|
|
483
|
+
#batchInputs = new BatchInputManager();
|
|
469
484
|
#prefetchedImageCount = 0;
|
|
470
485
|
|
|
471
486
|
constructor({
|
|
@@ -523,6 +538,40 @@ export class WecomHarnessBridge {
|
|
|
523
538
|
const pending = this.#pendingInteractions.get(key);
|
|
524
539
|
const commandMessage = wecomInboundMessage(frame, this.#client);
|
|
525
540
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
541
|
+
const batchCommand = isBatchInputCommand(commandText);
|
|
542
|
+
const batchStatus = this.#batchInputs.status(key);
|
|
543
|
+
if (batchCommand && body.chattype === 'group') {
|
|
544
|
+
return this.#finishBatchResult(
|
|
545
|
+
frame,
|
|
546
|
+
messageId,
|
|
547
|
+
chatId,
|
|
548
|
+
{ message: batchInputGroupUnsupportedMessage() },
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (body.chattype === 'single'
|
|
552
|
+
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
|
553
|
+
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
|
554
|
+
const result = exactBatchStart
|
|
555
|
+
&& batchStatus.phase === 'idle'
|
|
556
|
+
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
|
557
|
+
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
|
558
|
+
: this.#batchInputs.handle(key, commandText, {
|
|
559
|
+
plainText: isNativeWecomText(frame),
|
|
560
|
+
});
|
|
561
|
+
if (result.handled) {
|
|
562
|
+
if (result.kind === 'submit') {
|
|
563
|
+
return this.#enqueueMessage({
|
|
564
|
+
...frame,
|
|
565
|
+
body: {
|
|
566
|
+
...body,
|
|
567
|
+
msgtype: 'text',
|
|
568
|
+
text: { content: result.prompt },
|
|
569
|
+
},
|
|
570
|
+
}, messageId, key, { batchSubmission: result });
|
|
571
|
+
}
|
|
572
|
+
return this.#finishBatchResult(frame, messageId, chatId, result);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
526
575
|
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
|
527
576
|
? runControlCommand
|
|
528
577
|
: (isModelCommand(commandText)
|
|
@@ -616,6 +665,7 @@ export class WecomHarnessBridge {
|
|
|
616
665
|
#enqueueMessage(frame, messageId, key, {
|
|
617
666
|
releaseMessageId = true,
|
|
618
667
|
alreadyRecorded = false,
|
|
668
|
+
batchSubmission = null,
|
|
619
669
|
} = {}) {
|
|
620
670
|
// WeCom image URLs expire after five minutes, while a conversation turn
|
|
621
671
|
// may legally stay queued longer. Start the authenticated SDK download as
|
|
@@ -637,7 +687,11 @@ export class WecomHarnessBridge {
|
|
|
637
687
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
638
688
|
const current = previous
|
|
639
689
|
.catch(() => undefined)
|
|
640
|
-
.then(() => this.#process(frame, {
|
|
690
|
+
.then(() => this.#process(frame, {
|
|
691
|
+
alreadyRecorded,
|
|
692
|
+
preparedMessage,
|
|
693
|
+
batchSubmission,
|
|
694
|
+
}))
|
|
641
695
|
.finally(() => {
|
|
642
696
|
this.#prefetchedImageCount -= reservedImages;
|
|
643
697
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
@@ -647,6 +701,29 @@ export class WecomHarnessBridge {
|
|
|
647
701
|
return current;
|
|
648
702
|
}
|
|
649
703
|
|
|
704
|
+
#finishBatchResult(frame, messageId, chatId, result) {
|
|
705
|
+
let task;
|
|
706
|
+
task = Promise.resolve().then(async () => {
|
|
707
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
708
|
+
await this.#state.markSeen(messageId);
|
|
709
|
+
this.#status.messagesReceived += 1;
|
|
710
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
711
|
+
if (result.message) await this.#sendImmediate(frame, chatId, result.message);
|
|
712
|
+
this.#status.lastError = null;
|
|
713
|
+
}).catch(async (error) => {
|
|
714
|
+
if (this.#signal?.aborted) return;
|
|
715
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
716
|
+
this.#logger.error?.('[dsh-im:wecom] failed to process a batch input message');
|
|
717
|
+
await this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
|
|
718
|
+
.catch(() => undefined);
|
|
719
|
+
}).finally(() => {
|
|
720
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
721
|
+
this.#commandTasks.delete(task);
|
|
722
|
+
});
|
|
723
|
+
this.#commandTasks.add(task);
|
|
724
|
+
return task;
|
|
725
|
+
}
|
|
726
|
+
|
|
650
727
|
async waitForIdle() {
|
|
651
728
|
await Promise.allSettled([
|
|
652
729
|
...this.#queues.values(),
|
|
@@ -748,7 +825,11 @@ export class WecomHarnessBridge {
|
|
|
748
825
|
};
|
|
749
826
|
}
|
|
750
827
|
|
|
751
|
-
async #process(frame, {
|
|
828
|
+
async #process(frame, {
|
|
829
|
+
alreadyRecorded = false,
|
|
830
|
+
preparedMessage,
|
|
831
|
+
batchSubmission = null,
|
|
832
|
+
} = {}) {
|
|
752
833
|
if (this.#signal?.aborted) return;
|
|
753
834
|
const body = bodyOf(frame);
|
|
754
835
|
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
|
|
@@ -767,6 +848,7 @@ export class WecomHarnessBridge {
|
|
|
767
848
|
const key = conversationKey(frame);
|
|
768
849
|
let streamId = null;
|
|
769
850
|
let streamStarted = false;
|
|
851
|
+
let batchSettled = batchSubmission === null;
|
|
770
852
|
try {
|
|
771
853
|
if (!text && !hasImages && !hasFiles) {
|
|
772
854
|
await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
|
|
@@ -855,6 +937,10 @@ export class WecomHarnessBridge {
|
|
|
855
937
|
files: message.files,
|
|
856
938
|
},
|
|
857
939
|
});
|
|
940
|
+
if (batchSubmission) {
|
|
941
|
+
this.#batchInputs.complete(key, batchSubmission.token);
|
|
942
|
+
batchSettled = true;
|
|
943
|
+
}
|
|
858
944
|
|
|
859
945
|
this.#signal?.throwIfAborted();
|
|
860
946
|
const displayAnswer = answerTextForDelivery(answer, artifacts);
|
|
@@ -915,6 +1001,15 @@ export class WecomHarnessBridge {
|
|
|
915
1001
|
this.#status.lastError = null;
|
|
916
1002
|
return delivery.receipt;
|
|
917
1003
|
} catch (error) {
|
|
1004
|
+
let batchFailureMessage = null;
|
|
1005
|
+
if (!batchSettled && batchSubmission) {
|
|
1006
|
+
if (error?.code === 'turn-stopped') {
|
|
1007
|
+
this.#batchInputs.complete(key, batchSubmission.token);
|
|
1008
|
+
} else {
|
|
1009
|
+
batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
|
|
1010
|
+
}
|
|
1011
|
+
batchSettled = true;
|
|
1012
|
+
}
|
|
918
1013
|
if (error?.code === 'turn-stopped') {
|
|
919
1014
|
if (streamStarted && streamId) {
|
|
920
1015
|
await this.#client.replyStream(frame, streamId, t('已停止。'), true)
|
|
@@ -929,11 +1024,14 @@ export class WecomHarnessBridge {
|
|
|
929
1024
|
const errorText = inboundFileUserMessage(error)
|
|
930
1025
|
?? imagePromptUserMessage(error)
|
|
931
1026
|
?? t('消息处理失败,请稍后重试。');
|
|
1027
|
+
const visibleError = batchFailureMessage
|
|
1028
|
+
? `${errorText}\n\n${batchFailureMessage}`
|
|
1029
|
+
: errorText;
|
|
932
1030
|
try {
|
|
933
1031
|
if (streamStarted && streamId) {
|
|
934
|
-
await this.#client.replyStream(frame, streamId,
|
|
1032
|
+
await this.#client.replyStream(frame, streamId, visibleError, true);
|
|
935
1033
|
} else {
|
|
936
|
-
await this.#sendImmediate(frame, chatId,
|
|
1034
|
+
await this.#sendImmediate(frame, chatId, visibleError);
|
|
937
1035
|
}
|
|
938
1036
|
await this.#state.markSeen(messageId);
|
|
939
1037
|
} catch {
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
validHarnessQuestion,
|
|
13
13
|
} from '../shared/harness-question.mjs';
|
|
14
14
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
15
|
+
import {
|
|
16
|
+
BatchInputManager,
|
|
17
|
+
batchInputBusyMessage,
|
|
18
|
+
isBatchInputCommand,
|
|
19
|
+
} from '../shared/batch-input.mjs';
|
|
15
20
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
16
21
|
import {
|
|
17
22
|
isControlCommand,
|
|
@@ -70,7 +75,11 @@ const HELP_TEXT = () => [
|
|
|
70
75
|
t('/preset --default 跟随 Host 默认'),
|
|
71
76
|
t('/stop 停止当前任务'),
|
|
72
77
|
t('/steer 补充指令 纠偏当前任务'),
|
|
78
|
+
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
|
79
|
+
t('/send 提交当前批次'),
|
|
80
|
+
t('/cancel 取消当前批次'),
|
|
73
81
|
t('/status 检查连接状态'),
|
|
82
|
+
t('/version 查看插件版本'),
|
|
74
83
|
t('/help 显示本帮助'),
|
|
75
84
|
].join('\n');
|
|
76
85
|
|
|
@@ -104,6 +113,15 @@ function hasWeixinFileItems(message) {
|
|
|
104
113
|
&& message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
|
|
105
114
|
}
|
|
106
115
|
|
|
116
|
+
function isNativeWeixinText(message) {
|
|
117
|
+
return Array.isArray(message?.item_list)
|
|
118
|
+
&& message.item_list.length > 0
|
|
119
|
+
&& message.item_list.every((item) => (
|
|
120
|
+
item?.type === 1 && typeof item.text_item?.text === 'string'
|
|
121
|
+
))
|
|
122
|
+
&& Boolean(nonEmptyString(extractWeixinText(message)));
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
function canClaimInteractionReply(message, pending) {
|
|
108
126
|
return pending.questions[pending.index]
|
|
109
127
|
&& nonEmptyString(message?.from_user_id) === pending.actor
|
|
@@ -176,6 +194,7 @@ export class WeixinHarnessBridge {
|
|
|
176
194
|
#approvalTasks = new Set();
|
|
177
195
|
#commandTasks = new Set();
|
|
178
196
|
#approvals;
|
|
197
|
+
#batchInputs = new BatchInputManager();
|
|
179
198
|
|
|
180
199
|
constructor({
|
|
181
200
|
api,
|
|
@@ -227,6 +246,35 @@ export class WeixinHarnessBridge {
|
|
|
227
246
|
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
|
228
247
|
const pending = this.#pendingInteractions.get(key);
|
|
229
248
|
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
|
|
249
|
+
const batchCommand = isBatchInputCommand(commandText);
|
|
250
|
+
const batchStatus = this.#batchInputs.status(key);
|
|
251
|
+
if (sender === this.#ownerUserId
|
|
252
|
+
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
|
253
|
+
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
|
254
|
+
const result = exactBatchStart
|
|
255
|
+
&& batchStatus.phase === 'idle'
|
|
256
|
+
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
|
257
|
+
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
|
258
|
+
: this.#batchInputs.handle(key, commandText, {
|
|
259
|
+
plainText: isNativeWeixinText(message),
|
|
260
|
+
});
|
|
261
|
+
if (result.handled) {
|
|
262
|
+
if (result.kind === 'submit') {
|
|
263
|
+
return this.#enqueueMessage({
|
|
264
|
+
...message,
|
|
265
|
+
item_list: [{ type: 1, text_item: { text: result.prompt } }],
|
|
266
|
+
}, messageId, key, { batchSubmission: result });
|
|
267
|
+
}
|
|
268
|
+
return this.#finishBatchResult(
|
|
269
|
+
message,
|
|
270
|
+
messageId,
|
|
271
|
+
sender,
|
|
272
|
+
contextToken,
|
|
273
|
+
runId,
|
|
274
|
+
result,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
230
278
|
const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
|
|
231
279
|
? runControlCommand
|
|
232
280
|
: (isModelCommand(commandText)
|
|
@@ -314,6 +362,7 @@ export class WeixinHarnessBridge {
|
|
|
314
362
|
#enqueueMessage(message, messageId, key, {
|
|
315
363
|
releaseMessageId = true,
|
|
316
364
|
alreadyRecorded = false,
|
|
365
|
+
batchSubmission = null,
|
|
317
366
|
} = {}) {
|
|
318
367
|
const preparedMessage = message.from_user_id === this.#ownerUserId
|
|
319
368
|
? prefetchInboundFiles(
|
|
@@ -324,7 +373,11 @@ export class WeixinHarnessBridge {
|
|
|
324
373
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
325
374
|
const current = previous
|
|
326
375
|
.catch(() => undefined)
|
|
327
|
-
.then(() => this.#process(message, key, {
|
|
376
|
+
.then(() => this.#process(message, key, {
|
|
377
|
+
alreadyRecorded,
|
|
378
|
+
preparedMessage,
|
|
379
|
+
batchSubmission,
|
|
380
|
+
}))
|
|
328
381
|
.finally(() => {
|
|
329
382
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
330
383
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
@@ -333,6 +386,33 @@ export class WeixinHarnessBridge {
|
|
|
333
386
|
return current;
|
|
334
387
|
}
|
|
335
388
|
|
|
389
|
+
#finishBatchResult(message, messageId, sender, contextToken, runId, result) {
|
|
390
|
+
let task;
|
|
391
|
+
task = Promise.resolve().then(async () => {
|
|
392
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
393
|
+
await this.#state.markSeen(messageId);
|
|
394
|
+
this.#status.messagesReceived += 1;
|
|
395
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
396
|
+
if (result.message) {
|
|
397
|
+
await this.#send(sender, result.message, contextToken, runId);
|
|
398
|
+
}
|
|
399
|
+
this.#status.lastError = null;
|
|
400
|
+
this.#status.lastMessageError = null;
|
|
401
|
+
}).catch(async (error) => {
|
|
402
|
+
if (this.#signal?.aborted) return;
|
|
403
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
404
|
+
this.#status.lastMessageError = safeMessageError(error);
|
|
405
|
+
this.#logger.error?.('[dsh-weixin] failed to process a batch input message:', error);
|
|
406
|
+
await this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
|
|
407
|
+
.catch(() => undefined);
|
|
408
|
+
}).finally(() => {
|
|
409
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
410
|
+
this.#commandTasks.delete(task);
|
|
411
|
+
});
|
|
412
|
+
this.#commandTasks.add(task);
|
|
413
|
+
return task;
|
|
414
|
+
}
|
|
415
|
+
|
|
336
416
|
async waitForIdle() {
|
|
337
417
|
await Promise.allSettled([
|
|
338
418
|
...this.#queues.values(),
|
|
@@ -380,7 +460,11 @@ export class WeixinHarnessBridge {
|
|
|
380
460
|
this.#status.lastMessageError = null;
|
|
381
461
|
}
|
|
382
462
|
|
|
383
|
-
async #process(message, key, {
|
|
463
|
+
async #process(message, key, {
|
|
464
|
+
alreadyRecorded = false,
|
|
465
|
+
preparedMessage,
|
|
466
|
+
batchSubmission = null,
|
|
467
|
+
} = {}) {
|
|
384
468
|
this.#signal?.throwIfAborted();
|
|
385
469
|
const messageId = weixinMessageId(message);
|
|
386
470
|
const sender = nonEmptyString(message?.from_user_id);
|
|
@@ -398,6 +482,7 @@ export class WeixinHarnessBridge {
|
|
|
398
482
|
|
|
399
483
|
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
|
400
484
|
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
|
485
|
+
let batchSettled = batchSubmission === null;
|
|
401
486
|
try {
|
|
402
487
|
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
|
|
403
488
|
const text = promptMessage.content;
|
|
@@ -479,6 +564,10 @@ export class WeixinHarnessBridge {
|
|
|
479
564
|
files: promptMessage.files,
|
|
480
565
|
},
|
|
481
566
|
}));
|
|
567
|
+
if (batchSubmission) {
|
|
568
|
+
this.#batchInputs.complete(key, batchSubmission.token);
|
|
569
|
+
batchSettled = true;
|
|
570
|
+
}
|
|
482
571
|
} finally {
|
|
483
572
|
await Promise.allSettled([
|
|
484
573
|
this.#cancelPendingInteraction(key),
|
|
@@ -515,6 +604,15 @@ export class WeixinHarnessBridge {
|
|
|
515
604
|
this.#status.lastMessageError = null;
|
|
516
605
|
return delivery.receipt;
|
|
517
606
|
} catch (error) {
|
|
607
|
+
let batchFailureMessage = null;
|
|
608
|
+
if (!batchSettled && batchSubmission) {
|
|
609
|
+
if (error?.code === 'turn-stopped') {
|
|
610
|
+
this.#batchInputs.complete(key, batchSubmission.token);
|
|
611
|
+
} else {
|
|
612
|
+
batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
|
|
613
|
+
}
|
|
614
|
+
batchSettled = true;
|
|
615
|
+
}
|
|
518
616
|
if (error?.code === 'turn-stopped') {
|
|
519
617
|
await this.#state.markSeen(messageId);
|
|
520
618
|
return;
|
|
@@ -529,7 +627,7 @@ export class WeixinHarnessBridge {
|
|
|
529
627
|
try {
|
|
530
628
|
await this.#send(
|
|
531
629
|
sender,
|
|
532
|
-
userMessage,
|
|
630
|
+
batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
|
|
533
631
|
contextToken,
|
|
534
632
|
runId,
|
|
535
633
|
);
|