@xmanrui/dsh-im 2.2.0 → 2.3.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.
@@ -0,0 +1,209 @@
1
+ import { t } from './i18n.mjs';
2
+
3
+ export const BATCH_INPUT_LIMIT = 10;
4
+
5
+ const BATCH_COMMAND = /^\/(batch|send|cancel)(?=$|\s)/iu;
6
+ const EXACT_BATCH_COMMAND = /^\/(batch|send|cancel)$/iu;
7
+
8
+ function commandName(text) {
9
+ if (typeof text !== 'string') return null;
10
+ return BATCH_COMMAND.exec(text.trim())?.[1]?.toLowerCase() ?? null;
11
+ }
12
+
13
+ function result(kind, message, extra = {}) {
14
+ return { handled: true, kind, ...(message ? { message } : {}), ...extra };
15
+ }
16
+
17
+ function progressMessage(count) {
18
+ if (count === BATCH_INPUT_LIMIT) {
19
+ return t(`当前已处于批量输入模式,已收集 {count}/{limit} 条。
20
+ 请发送 /send 提交或 /cancel 取消。`, { count, limit: BATCH_INPUT_LIMIT });
21
+ }
22
+ return t(`当前已处于批量输入模式,已收集 {count}/{limit} 条。
23
+ 完成后发送 /send,取消请发送 /cancel。`, { count, limit: BATCH_INPUT_LIMIT });
24
+ }
25
+
26
+ function submissionPrompt(messages) {
27
+ const sections = messages.map((message, index) => (
28
+ `${t('[消息 {index}]', { index: index + 1 })}\n${message}`
29
+ ));
30
+ return [
31
+ t('以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。'),
32
+ ...sections,
33
+ ].join('\n\n');
34
+ }
35
+
36
+ export function isBatchInputCommand(text) {
37
+ return commandName(text) !== null;
38
+ }
39
+
40
+ export function batchInputGroupUnsupportedMessage() {
41
+ return t('批量输入模式仅支持私聊,请在与机器人的私聊中使用。');
42
+ }
43
+
44
+ export function batchInputBusyMessage() {
45
+ return t(`当前聊天有正在运行的任务、待回答问题或待审批请求。
46
+ 请先完成当前交互或发送 /stop,再使用 /batch。`);
47
+ }
48
+
49
+ export class BatchInputManager {
50
+ #batches = new Map();
51
+
52
+ status(key) {
53
+ const batch = this.#batches.get(key);
54
+ if (!batch) {
55
+ return Object.freeze({ phase: 'idle', count: 0, limit: BATCH_INPUT_LIMIT, full: false });
56
+ }
57
+ return Object.freeze({
58
+ phase: batch.phase,
59
+ count: batch.messages.length,
60
+ limit: BATCH_INPUT_LIMIT,
61
+ full: batch.messages.length === BATCH_INPUT_LIMIT,
62
+ });
63
+ }
64
+
65
+ handle(key, text, { plainText = true } = {}) {
66
+ const batch = this.#batches.get(key);
67
+ const name = commandName(text);
68
+ const exact = typeof text === 'string' ? EXACT_BATCH_COMMAND.exec(text.trim()) : null;
69
+
70
+ if (name && !exact) {
71
+ return result('invalid-command', t('用法:/{command}(不带参数)', { command: name }));
72
+ }
73
+
74
+ if (!batch) {
75
+ if (!name) return { handled: false };
76
+ if (!plainText) {
77
+ return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片或文件后重试。'));
78
+ }
79
+ if (name === 'send') {
80
+ return result('no-batch', t('当前没有待提交的批量内容,请先发送 /batch。'));
81
+ }
82
+ if (name === 'cancel') {
83
+ return result('no-batch', t('当前没有正在进行的批量输入。'));
84
+ }
85
+ this.#batches.set(key, { phase: 'collecting', messages: [], token: null });
86
+ return result('started', t(`已进入批量输入模式,最多可发送 {limit} 条文字。
87
+ 完成后发送 /send,取消请发送 /cancel。`, { limit: BATCH_INPUT_LIMIT }), {
88
+ count: 0,
89
+ limit: BATCH_INPUT_LIMIT,
90
+ });
91
+ }
92
+
93
+ if (!plainText && (batch.phase === 'collecting' || name)) {
94
+ return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
95
+ 请继续发送文字,或使用 /send、/cancel。`), {
96
+ count: batch.messages.length,
97
+ limit: BATCH_INPUT_LIMIT,
98
+ });
99
+ }
100
+
101
+ if (batch.phase === 'submitting') {
102
+ if (name === 'send') {
103
+ return result('submitting', t('当前批次正在提交,请勿重复发送 /send。'));
104
+ }
105
+ if (name === 'cancel') {
106
+ return result('submitting', t(`批量内容已经提交,无法取消。
107
+ 如需停止当前任务,请发送 /stop。`));
108
+ }
109
+ if (name === 'batch') {
110
+ return result('submitting', t('当前批次正在提交,请等待处理完成后再开启新批次。'));
111
+ }
112
+ return { handled: false };
113
+ }
114
+
115
+ if (name === 'batch') {
116
+ return result('status', progressMessage(batch.messages.length), {
117
+ count: batch.messages.length,
118
+ limit: BATCH_INPUT_LIMIT,
119
+ });
120
+ }
121
+
122
+ if (name === 'cancel') {
123
+ const count = batch.messages.length;
124
+ this.#batches.delete(key);
125
+ return result('cancelled', count === 0
126
+ ? t('已取消批量输入。')
127
+ : t('已取消批量输入,共丢弃 {count} 条消息。', { count }), { count });
128
+ }
129
+
130
+ if (name === 'send') {
131
+ if (batch.messages.length === 0) {
132
+ return result('empty', t('当前批次还没有内容,请先发送文字,或使用 /cancel 取消。'), {
133
+ count: 0,
134
+ });
135
+ }
136
+ const messages = Object.freeze([...batch.messages]);
137
+ const token = Object.freeze({});
138
+ batch.phase = 'submitting';
139
+ batch.token = token;
140
+ return result('submit', null, {
141
+ token,
142
+ messages,
143
+ prompt: submissionPrompt(messages),
144
+ count: messages.length,
145
+ });
146
+ }
147
+
148
+ if (typeof text !== 'string') {
149
+ return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
150
+ 请继续发送文字,或使用 /send、/cancel。`), {
151
+ count: batch.messages.length,
152
+ limit: BATCH_INPUT_LIMIT,
153
+ });
154
+ }
155
+
156
+ if (text.trim().startsWith('/')) {
157
+ return result('blocked-command', t('当前正在批量输入,请先发送 /send 提交或 /cancel 取消。'), {
158
+ count: batch.messages.length,
159
+ limit: BATCH_INPUT_LIMIT,
160
+ });
161
+ }
162
+
163
+ if (batch.messages.length === BATCH_INPUT_LIMIT) {
164
+ return result('full', t(`当前批次已满,这条消息未收录。
165
+ 请先发送 /send 提交或 /cancel 取消,然后重新发送这条消息。`), {
166
+ count: BATCH_INPUT_LIMIT,
167
+ limit: BATCH_INPUT_LIMIT,
168
+ });
169
+ }
170
+
171
+ batch.messages.push(text);
172
+ const count = batch.messages.length;
173
+ return result('collected', count === BATCH_INPUT_LIMIT
174
+ ? t('已收集 {count}/{limit} 条,当前批次已满,请发送 /send 提交或 /cancel 取消。', {
175
+ count,
176
+ limit: BATCH_INPUT_LIMIT,
177
+ })
178
+ : null, {
179
+ count,
180
+ limit: BATCH_INPUT_LIMIT,
181
+ });
182
+ }
183
+
184
+ complete(key, token) {
185
+ const batch = this.#batches.get(key);
186
+ if (!batch || batch.phase !== 'submitting' || batch.token !== token) {
187
+ return Object.freeze({ completed: false });
188
+ }
189
+ const count = batch.messages.length;
190
+ this.#batches.delete(key);
191
+ return Object.freeze({ completed: true, count });
192
+ }
193
+
194
+ fail(key, token) {
195
+ const batch = this.#batches.get(key);
196
+ if (!batch || batch.phase !== 'submitting' || batch.token !== token) {
197
+ return Object.freeze({ retained: false });
198
+ }
199
+ batch.phase = 'collecting';
200
+ batch.token = null;
201
+ const count = batch.messages.length;
202
+ return Object.freeze({
203
+ retained: true,
204
+ count,
205
+ message: t(`批量内容提交失败,已保留 {count} 条消息。
206
+ 请再次发送 /send 重试或 /cancel 取消。`, { count }),
207
+ });
208
+ }
209
+ }
@@ -234,14 +234,15 @@ export default {
234
234
  '/watch ID 关注会话(完成后推送)': '/watch ID Watch a session (push on completion)',
235
235
  '/watchlist 关注列表': '/watchlist List watched sessions',
236
236
  '/unwatch ID 取消关注': '/unwatch ID Stop watching a session',
237
+ '📦 批量输入(仅私聊)': '📦 Batch input (direct messages only)',
237
238
  '🤖 预设 / 模型': '🤖 Presets / models',
238
239
  '/models 列出模型': '/models List models',
239
240
  '🎮 任务控制': '🎮 Task controls',
240
241
  '/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
241
242
  '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
242
243
  '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
243
- '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/repair` — 修复卡片按钮':
244
- '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/repair` — repair card buttons',
244
+ '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 修复卡片按钮':
245
+ '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — repair card buttons',
245
246
  '**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**修复 · **6**帮助':
246
247
  '**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Repair · **6** Help',
247
248
  '从下方下拉选择补充指令;最后一项可自定义输入。':
@@ -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
  };
@@ -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 commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
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,6 +490,9 @@ 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 检查连接状态'),
415
497
  t('/help 显示本帮助'),
416
498
  ].join('\n'));
@@ -508,6 +590,9 @@ export class TextHarnessBridge {
508
590
  files: message.files,
509
591
  },
510
592
  });
593
+ if (batchSubmission) {
594
+ this.#batches.complete(conversationKey, batchSubmission.token);
595
+ }
511
596
  const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
512
597
  const visibleAnswer = fileOnlyCompletion
513
598
  ? t(FILE_ONLY_COMPLETION_TEXT)
@@ -579,7 +664,14 @@ export class TextHarnessBridge {
579
664
  }
580
665
  return delivery.receipt;
581
666
  } catch (error) {
582
- if (error?.code === 'turn-stopped') {
667
+ const turnStopped = error?.code === 'turn-stopped';
668
+ if (batchSubmission && turnStopped) {
669
+ this.#batches.complete(conversationKey, batchSubmission.token);
670
+ }
671
+ const failedBatch = batchSubmission && !turnStopped
672
+ ? this.#batches.fail(conversationKey, batchSubmission.token)
673
+ : null;
674
+ if (turnStopped) {
583
675
  if (stream) {
584
676
  try {
585
677
  await stream.finish(t('已停止。'));
@@ -607,6 +699,23 @@ export class TextHarnessBridge {
607
699
  return false;
608
700
  }
609
701
  };
702
+ if (failedBatch?.retained) {
703
+ this.#logger.error?.(
704
+ `[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
705
+ error,
706
+ );
707
+ if (await presentStreamFailure(failedBatch.message)) return;
708
+ stream?.cancel?.();
709
+ try {
710
+ await this.#bot.sendText(target, failedBatch.message);
711
+ } catch (sendError) {
712
+ this.#logger.error?.(
713
+ `[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
714
+ sendError,
715
+ );
716
+ }
717
+ return;
718
+ }
610
719
  const imageErrorMessage = imagePromptUserMessage(error);
611
720
  if (imageErrorMessage) {
612
721
  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,6 +28,9 @@ 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: '检查连接状态' },
32
35
  { command: 'help', description: '显示帮助' },
33
36
  ]);
@@ -160,6 +163,7 @@ export function normalizeTelegramUpdate(update, {
160
163
  conversationId: messageThreadId === undefined
161
164
  ? String(chatId) : `${chatId}:${messageThreadId}`,
162
165
  content: withoutBotMention(message.text ?? message.caption ?? '', username),
166
+ plainText: typeof message.text === 'string',
163
167
  images: image ? [image] : [],
164
168
  files: file ? [file] : [],
165
169
  addressed,