feat: add private chat batch input commands

This commit is contained in:
xmanrui 2026-08-25 02:39:31 +08:00
parent f6cc1cae30
commit 068cf57259
28 changed files with 2665 additions and 219 deletions

View file

@ -0,0 +1,209 @@
import { t } from './i18n.mjs';
export const BATCH_INPUT_LIMIT = 10;
const BATCH_COMMAND = /^\/(batch|send|cancel)(?=$|\s)/iu;
const EXACT_BATCH_COMMAND = /^\/(batch|send|cancel)$/iu;
function commandName(text) {
if (typeof text !== 'string') return null;
return BATCH_COMMAND.exec(text.trim())?.[1]?.toLowerCase() ?? null;
}
function result(kind, message, extra = {}) {
return { handled: true, kind, ...(message ? { message } : {}), ...extra };
}
function progressMessage(count) {
if (count === BATCH_INPUT_LIMIT) {
return t(`当前已处于批量输入模式,已收集 {count}/{limit} 条。
请发送 /send 提交或 /cancel 取消。`, { count, limit: BATCH_INPUT_LIMIT });
}
return t(`当前已处于批量输入模式,已收集 {count}/{limit} 条。
完成后发送 /send,取消请发送 /cancel。`, { count, limit: BATCH_INPUT_LIMIT });
}
function submissionPrompt(messages) {
const sections = messages.map((message, index) => (
`${t('[消息 {index}]', { index: index + 1 })}\n${message}`
));
return [
t('以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。'),
...sections,
].join('\n\n');
}
export function isBatchInputCommand(text) {
return commandName(text) !== null;
}
export function batchInputGroupUnsupportedMessage() {
return t('批量输入模式仅支持私聊,请在与机器人的私聊中使用。');
}
export function batchInputBusyMessage() {
return t(`当前聊天有正在运行的任务、待回答问题或待审批请求。
请先完成当前交互或发送 /stop,再使用 /batch。`);
}
export class BatchInputManager {
#batches = new Map();
status(key) {
const batch = this.#batches.get(key);
if (!batch) {
return Object.freeze({ phase: 'idle', count: 0, limit: BATCH_INPUT_LIMIT, full: false });
}
return Object.freeze({
phase: batch.phase,
count: batch.messages.length,
limit: BATCH_INPUT_LIMIT,
full: batch.messages.length === BATCH_INPUT_LIMIT,
});
}
handle(key, text, { plainText = true } = {}) {
const batch = this.#batches.get(key);
const name = commandName(text);
const exact = typeof text === 'string' ? EXACT_BATCH_COMMAND.exec(text.trim()) : null;
if (name && !exact) {
return result('invalid-command', t('用法:/{command}(不带参数)', { command: name }));
}
if (!batch) {
if (!name) return { handled: false };
if (!plainText) {
return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片或文件后重试。'));
}
if (name === 'send') {
return result('no-batch', t('当前没有待提交的批量内容,请先发送 /batch。'));
}
if (name === 'cancel') {
return result('no-batch', t('当前没有正在进行的批量输入。'));
}
this.#batches.set(key, { phase: 'collecting', messages: [], token: null });
return result('started', t(`已进入批量输入模式,最多可发送 {limit} 条文字。
完成后发送 /send,取消请发送 /cancel。`, { limit: BATCH_INPUT_LIMIT }), {
count: 0,
limit: BATCH_INPUT_LIMIT,
});
}
if (!plainText && (batch.phase === 'collecting' || name)) {
return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
请继续发送文字,或使用 /send、/cancel。`), {
count: batch.messages.length,
limit: BATCH_INPUT_LIMIT,
});
}
if (batch.phase === 'submitting') {
if (name === 'send') {
return result('submitting', t('当前批次正在提交,请勿重复发送 /send。'));
}
if (name === 'cancel') {
return result('submitting', t(`批量内容已经提交,无法取消。
如需停止当前任务,请发送 /stop。`));
}
if (name === 'batch') {
return result('submitting', t('当前批次正在提交,请等待处理完成后再开启新批次。'));
}
return { handled: false };
}
if (name === 'batch') {
return result('status', progressMessage(batch.messages.length), {
count: batch.messages.length,
limit: BATCH_INPUT_LIMIT,
});
}
if (name === 'cancel') {
const count = batch.messages.length;
this.#batches.delete(key);
return result('cancelled', count === 0
? t('已取消批量输入。')
: t('已取消批量输入,共丢弃 {count} 条消息。', { count }), { count });
}
if (name === 'send') {
if (batch.messages.length === 0) {
return result('empty', t('当前批次还没有内容,请先发送文字,或使用 /cancel 取消。'), {
count: 0,
});
}
const messages = Object.freeze([...batch.messages]);
const token = Object.freeze({});
batch.phase = 'submitting';
batch.token = token;
return result('submit', null, {
token,
messages,
prompt: submissionPrompt(messages),
count: messages.length,
});
}
if (typeof text !== 'string') {
return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
请继续发送文字,或使用 /send、/cancel。`), {
count: batch.messages.length,
limit: BATCH_INPUT_LIMIT,
});
}
if (text.trim().startsWith('/')) {
return result('blocked-command', t('当前正在批量输入,请先发送 /send 提交或 /cancel 取消。'), {
count: batch.messages.length,
limit: BATCH_INPUT_LIMIT,
});
}
if (batch.messages.length === BATCH_INPUT_LIMIT) {
return result('full', t(`当前批次已满,这条消息未收录。
请先发送 /send 提交或 /cancel 取消,然后重新发送这条消息。`), {
count: BATCH_INPUT_LIMIT,
limit: BATCH_INPUT_LIMIT,
});
}
batch.messages.push(text);
const count = batch.messages.length;
return result('collected', count === BATCH_INPUT_LIMIT
? t('已收集 {count}/{limit} 条,当前批次已满,请发送 /send 提交或 /cancel 取消。', {
count,
limit: BATCH_INPUT_LIMIT,
})
: null, {
count,
limit: BATCH_INPUT_LIMIT,
});
}
complete(key, token) {
const batch = this.#batches.get(key);
if (!batch || batch.phase !== 'submitting' || batch.token !== token) {
return Object.freeze({ completed: false });
}
const count = batch.messages.length;
this.#batches.delete(key);
return Object.freeze({ completed: true, count });
}
fail(key, token) {
const batch = this.#batches.get(key);
if (!batch || batch.phase !== 'submitting' || batch.token !== token) {
return Object.freeze({ retained: false });
}
batch.phase = 'collecting';
batch.token = null;
const count = batch.messages.length;
return Object.freeze({
retained: true,
count,
message: t(`批量内容提交失败,已保留 {count} 条消息。
请再次发送 /send 重试或 /cancel 取消。`, { count }),
});
}
}

View file

@ -234,14 +234,15 @@ export default {
'/watch ID 关注会话(完成后推送)': '/watch ID Watch a session (push on completion)',
'/watchlist 关注列表': '/watchlist List watched sessions',
'/unwatch ID 取消关注': '/unwatch ID Stop watching a session',
'📦 批量输入(仅私聊)': '📦 Batch input (direct messages only)',
'🤖 预设 / 模型': '🤖 Presets / models',
'/models 列出模型': '/models List models',
'🎮 任务控制': '🎮 Task controls',
'/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
'**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
'**📋 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',
'**⌨️ 文本命令**\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` — 修复卡片按钮':
'**⌨️ 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',
'**⌨️ 文本命令**\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` — 修复卡片按钮':
'**⌨️ 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',
'**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**修复 · **6**帮助':
'**💡 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',
'从下方下拉选择补充指令;最后一项可自定义输入。':

View file

@ -96,4 +96,53 @@ export default {
// agent-preset.mjs
'Agent Preset 无效。': 'Invalid Agent Preset.',
// batch-input.mjs
'/batch 开始批量输入(仅私聊,最多 10 条文字)':
'/batch Start batch input (direct messages only, up to 10 text messages)',
'/send 提交当前批次': '/send Submit the current batch',
'/cancel 取消当前批次': '/cancel Cancel the current batch',
'开始批量输入(仅私聊)': 'Start batch input (direct messages only)',
'提交当前批次': 'Submit the current batch',
'取消当前批次': 'Cancel the current batch',
'当前已处于批量输入模式,已收集 {count}/{limit} 条。\n请发送 /send 提交或 /cancel 取消。':
'Batch input is already active with {count}/{limit} messages collected.\nSend /send to submit or /cancel to cancel.',
'当前已处于批量输入模式,已收集 {count}/{limit} 条。\n完成后发送 /send,取消请发送 /cancel。':
'Batch input is already active with {count}/{limit} messages collected.\nSend /send when finished or /cancel to cancel.',
'[消息 {index}]': '[Message {index}]',
'以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。':
'The user sent the following messages in batch input mode. Process them in order as one input.',
'批量输入模式仅支持私聊,请在与机器人的私聊中使用。':
'Batch input is available only in direct messages. Please use it in a direct chat with the bot.',
'当前聊天有正在运行的任务、待回答问题或待审批请求。\n请先完成当前交互或发送 /stop,再使用 /batch。':
'This chat has a running task, unanswered question, or pending approval.\nFinish the current interaction or send /stop before using /batch.',
'用法:/{command}(不带参数)': 'Usage: /{command} (without arguments)',
'批量输入命令仅支持纯文字,请移除图片或文件后重试。':
'Batch input commands support text only. Remove the image or file and try again.',
'当前没有待提交的批量内容,请先发送 /batch。':
'There is no batch to submit. Send /batch first.',
'当前没有正在进行的批量输入。': 'There is no active batch input.',
'已进入批量输入模式,最多可发送 {limit} 条文字。\n完成后发送 /send,取消请发送 /cancel。':
'Batch input started. You can send up to {limit} text messages.\nSend /send when finished or /cancel to cancel.',
'批量输入模式目前仅支持文字,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
'Batch input currently supports text only, so this message was not collected.\nContinue with text, or use /send or /cancel.',
'当前批次正在提交,请勿重复发送 /send。':
'The current batch is being submitted. Do not send /send again.',
'批量内容已经提交,无法取消。\n如需停止当前任务,请发送 /stop。':
'The batch has already been submitted and cannot be cancelled.\nSend /stop if you need to stop the current task.',
'当前批次正在提交,请等待处理完成后再开启新批次。':
'The current batch is being submitted. Wait for it to finish before starting another batch.',
'已取消批量输入。': 'Batch input cancelled.',
'已取消批量输入,共丢弃 {count} 条消息。':
'Batch input cancelled; {count} messages were discarded.',
'当前批次还没有内容,请先发送文字,或使用 /cancel 取消。':
'The current batch is empty. Send some text first or use /cancel.',
'当前正在批量输入,请先发送 /send 提交或 /cancel 取消。':
'Batch input is active. Send /send to submit or /cancel to cancel first.',
'当前批次已满,这条消息未收录。\n请先发送 /send 提交或 /cancel 取消,然后重新发送这条消息。':
'The current batch is full, so this message was not collected.\nSend /send or /cancel first, then resend this message.',
'已收集 {count}/{limit} 条,当前批次已满,请发送 /send 提交或 /cancel 取消。':
'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
'批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
};

View file

@ -19,6 +19,12 @@ import {
} from './preset-command.mjs';
import { askInWorkspaceSession } from './workspace-session.mjs';
import { HarnessApprovalQueue } from './harness-approval.mjs';
import {
BatchInputManager,
batchInputBusyMessage,
batchInputGroupUnsupportedMessage,
isBatchInputCommand,
} from './batch-input.mjs';
import {
hasInboundImages,
imagePromptUserMessage,
@ -119,6 +125,7 @@ export class TextHarnessBridge {
#approvalTasks = new Set();
#commandTasks = new Set();
#approvals;
#batches = new BatchInputManager();
constructor({
descriptor,
@ -173,7 +180,54 @@ export class TextHarnessBridge {
const key = `${kind}:${conversationId}`;
const pending = this.#pendingInteractions.get(key);
const text = cleanText(normalized.content);
const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
const batchCommand = isBatchInputCommand(text);
if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
return this.#finishLocalMessage(
normalized,
messageId,
batchInputGroupUnsupportedMessage(),
);
}
if (batchCommand && normalized.kind === 'direct') {
const exactBatch = /^\/batch$/iu.test(text);
if (exactBatch && (
this.#queues.has(key)
|| Boolean(pending)
|| this.#approvals.hasPending(key)
)) {
return this.#finishLocalMessage(normalized, messageId, batchInputBusyMessage());
}
const batch = this.#batches.handle(key, text, {
plainText: Boolean(text)
&& normalized.plainText !== false
&& !hasInboundImages(normalized)
&& !hasInboundFiles(normalized),
});
if (batch.handled) {
if (batch.kind === 'submit') {
return this.#enqueueMessage({
...normalized,
content: batch.prompt,
batchSubmission: { token: batch.token },
}, messageId, senderId, key);
}
return this.#finishLocalMessage(normalized, messageId, batch.message);
}
} else if (normalized.kind === 'direct'
&& this.#batches.status(key).phase === 'collecting') {
const batch = this.#batches.handle(key, text, {
plainText: Boolean(text)
&& normalized.plainText !== false
&& !hasInboundImages(normalized)
&& !hasInboundFiles(normalized),
});
if (batch.handled) {
return this.#finishLocalMessage(normalized, messageId, batch.message);
}
}
const collectingBatch = normalized.kind === 'direct'
&& this.#batches.status(key).phase === 'collecting';
const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text)
? runControlCommand
: (isModelCommand(text)
? runModelCommand
@ -254,6 +308,30 @@ export class TextHarnessBridge {
return this.#enqueueMessage(normalized, messageId, senderId, key);
}
#finishLocalMessage(message, messageId, reply) {
let task;
task = (async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
if (reply) await this.#bot.sendText(message.replyTarget, reply);
this.#status.lastError = null;
})().catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
error,
);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
#enqueueMessage(message, messageId, senderId, key, {
releaseMessageId = true,
alreadyRecorded = false,
@ -373,6 +451,7 @@ export class TextHarnessBridge {
const target = message.replyTarget;
const text = cleanText(message.content);
const batchSubmission = message.batchSubmission;
let stream = null;
let semanticStream = false;
try {
@ -411,6 +490,9 @@ export class TextHarnessBridge {
t('/preset --default 跟随 Host 默认'),
t('/stop 停止当前任务'),
t('/steer 补充指令 纠偏当前任务'),
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
t('/send 提交当前批次'),
t('/cancel 取消当前批次'),
t('/status 检查连接状态'),
t('/help 显示本帮助'),
].join('\n'));
@ -508,6 +590,9 @@ export class TextHarnessBridge {
files: message.files,
},
});
if (batchSubmission) {
this.#batches.complete(conversationKey, batchSubmission.token);
}
const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
const visibleAnswer = fileOnlyCompletion
? t(FILE_ONLY_COMPLETION_TEXT)
@ -579,7 +664,14 @@ export class TextHarnessBridge {
}
return delivery.receipt;
} catch (error) {
if (error?.code === 'turn-stopped') {
const turnStopped = error?.code === 'turn-stopped';
if (batchSubmission && turnStopped) {
this.#batches.complete(conversationKey, batchSubmission.token);
}
const failedBatch = batchSubmission && !turnStopped
? this.#batches.fail(conversationKey, batchSubmission.token)
: null;
if (turnStopped) {
if (stream) {
try {
await stream.finish(t('已停止。'));
@ -607,6 +699,23 @@ export class TextHarnessBridge {
return false;
}
};
if (failedBatch?.retained) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
error,
);
if (await presentStreamFailure(failedBatch.message)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, failedBatch.message);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
sendError,
);
}
return;
}
const imageErrorMessage = imagePromptUserMessage(error);
if (imageErrorMessage) {
if (await presentStreamFailure(imageErrorMessage)) return;