@xmanrui/dsh-im 4.12.0 → 4.14.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/PROACTIVE_DELIVERY.en.md +4 -0
- package/PROACTIVE_DELIVERY.md +4 -0
- package/README.en.md +11 -2
- package/README.md +11 -2
- package/assets/dingtalk-menu-template.json +6 -0
- package/lib/client.js +78 -2
- package/lib/index.js +273 -264
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +57 -0
- package/plugin-src/client/i18n.js +5 -0
- package/plugin-src/client/styles.js +2 -1
- package/plugin-src/host/channels/feishu/production.mjs +1 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/harness-command-executor.mjs +13 -6
- package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
- package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
- package/src/channels/dingtalk/state-store.mjs +8 -0
- package/src/channels/feishu/bridge.mjs +584 -6
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/feishu/state-store.mjs +11 -0
- package/src/channels/qq/qq-bridge.mjs +121 -1
- package/src/channels/qq/qq-menu.mjs +205 -0
- package/src/channels/qq/state-store.mjs +16 -0
- package/src/channels/shared/bot-workspace-store.mjs +17 -1
- package/src/channels/shared/control-command.mjs +8 -0
- package/src/channels/shared/conversation-state-store.mjs +8 -0
- package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
- package/src/channels/shared/deferred-delivery.mjs +161 -0
- package/src/channels/shared/deferred-state.mjs +53 -0
- package/src/channels/shared/harness-client.mjs +42 -3
- package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +8 -0
- package/src/channels/shared/i18n-en/qq.mjs +39 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/semantic/artifact.mjs +3 -1
- package/src/channels/shared/text-harness-bridge.mjs +15 -0
- package/src/channels/shared/workspace-session.mjs +16 -1
- package/src/channels/wecom/state-store.mjs +8 -0
- package/src/channels/wecom/wecom-bridge.mjs +19 -4
- package/src/channels/weixin/state-store.mjs +64 -1
- package/src/channels/weixin/weixin-bridge.mjs +31 -5
- package/src/channels/weixin/weixin-runtime.mjs +33 -8
|
@@ -111,6 +111,7 @@ export class FeishuRuntime {
|
|
|
111
111
|
#botOpenId;
|
|
112
112
|
#groupResponseMode;
|
|
113
113
|
#groupTopicReply;
|
|
114
|
+
#stepPush;
|
|
114
115
|
#ownerOpenIds;
|
|
115
116
|
#harness;
|
|
116
117
|
#state;
|
|
@@ -141,6 +142,7 @@ export class FeishuRuntime {
|
|
|
141
142
|
botOpenId,
|
|
142
143
|
groupResponseMode,
|
|
143
144
|
groupTopicReply = false,
|
|
145
|
+
stepPush = false,
|
|
144
146
|
ownerOpenId,
|
|
145
147
|
ownerOpenIds,
|
|
146
148
|
harness,
|
|
@@ -177,6 +179,7 @@ export class FeishuRuntime {
|
|
|
177
179
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
178
180
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
179
181
|
this.#groupTopicReply = groupTopicReply === true;
|
|
182
|
+
this.#stepPush = stepPush === true;
|
|
180
183
|
this.#ownerOpenIds = normalizedOwners;
|
|
181
184
|
this.#harness = harness;
|
|
182
185
|
this.#state = state;
|
|
@@ -206,6 +209,11 @@ export class FeishuRuntime {
|
|
|
206
209
|
this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
setStepPush(value) {
|
|
213
|
+
this.#stepPush = value === true;
|
|
214
|
+
this.#bridge?.setStepPush(this.#stepPush);
|
|
215
|
+
}
|
|
216
|
+
|
|
209
217
|
async start() {
|
|
210
218
|
while (true) {
|
|
211
219
|
while (this.#stopping) await this.#stopping;
|
|
@@ -295,6 +303,7 @@ export class FeishuRuntime {
|
|
|
295
303
|
botOpenId: this.#botOpenId,
|
|
296
304
|
groupResponseMode: this.#groupResponseMode,
|
|
297
305
|
groupTopicReply: this.#groupTopicReply,
|
|
306
|
+
stepPush: this.#stepPush,
|
|
298
307
|
repair: this.#repair,
|
|
299
308
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
300
309
|
// Interaction cards (approval/question buttons) are on by default.
|
|
@@ -91,6 +91,7 @@ function configuredBotFingerprint(config) {
|
|
|
91
91
|
activated: config.activated,
|
|
92
92
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
93
93
|
groupTopicReply: config.groupTopicReply === true,
|
|
94
|
+
stepPush: config.stepPush === true,
|
|
94
95
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
95
96
|
deletionPending: config.deletionPending === true,
|
|
96
97
|
connectedAt: config.connectedAt ?? null,
|
|
@@ -599,6 +600,20 @@ export class MultiBotDshFeishuController {
|
|
|
599
600
|
}));
|
|
600
601
|
}
|
|
601
602
|
|
|
603
|
+
async updateStepPush(botId, stepPush) {
|
|
604
|
+
this.#assertOpen();
|
|
605
|
+
if (typeof stepPush !== 'boolean') {
|
|
606
|
+
throw new TypeError('Invalid Feishu step push flag');
|
|
607
|
+
}
|
|
608
|
+
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
609
|
+
const config = this.#requireBot(botId);
|
|
610
|
+
const saved = await this.#configStore.saveBot({ ...config, stepPush });
|
|
611
|
+
this.#runtimes.get(botId)?.setStepPush?.(saved.stepPush);
|
|
612
|
+
this.#touch();
|
|
613
|
+
return this.status(botId);
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
|
|
602
617
|
async deleteBot(botId) {
|
|
603
618
|
this.#assertOpen();
|
|
604
619
|
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
@@ -662,6 +677,7 @@ export class MultiBotDshFeishuController {
|
|
|
662
677
|
configured: true,
|
|
663
678
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
664
679
|
groupTopicReply: config.groupTopicReply === true,
|
|
680
|
+
stepPush: config.stepPush === true,
|
|
665
681
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
666
682
|
bot: publicBot(config),
|
|
667
683
|
connection,
|
|
@@ -46,6 +46,7 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|
|
46
46
|
activated: value.activated ?? null,
|
|
47
47
|
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
|
|
48
48
|
groupTopicReply: value.groupTopicReply === true,
|
|
49
|
+
stepPush: value.stepPush === true,
|
|
49
50
|
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
50
51
|
deletionPending: value.deletionPending === true,
|
|
51
52
|
connectedAt: cleanString(value.connectedAt),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
1
2
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
3
4
|
|
|
@@ -6,6 +7,7 @@ const EMPTY_STATE = Object.freeze({
|
|
|
6
7
|
sessions: {},
|
|
7
8
|
seenMessageIds: [],
|
|
8
9
|
watches: {},
|
|
10
|
+
deferred: {},
|
|
9
11
|
includeArchivedSessions: false,
|
|
10
12
|
topics: {},
|
|
11
13
|
});
|
|
@@ -25,6 +27,7 @@ export class StateStore {
|
|
|
25
27
|
#path;
|
|
26
28
|
#state = structuredClone(EMPTY_STATE);
|
|
27
29
|
#writeQueue = Promise.resolve();
|
|
30
|
+
#deferred = deferredStateAccess(() => this.#state, () => this.#persist());
|
|
28
31
|
|
|
29
32
|
constructor(path) {
|
|
30
33
|
this.#path = path;
|
|
@@ -38,6 +41,7 @@ export class StateStore {
|
|
|
38
41
|
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
|
|
39
42
|
seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
|
|
40
43
|
watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
|
|
44
|
+
deferred: normalizeDeferredState(parsed.deferred),
|
|
41
45
|
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
|
|
42
46
|
? parsed.includeArchivedSessions
|
|
43
47
|
: false,
|
|
@@ -50,6 +54,11 @@ export class StateStore {
|
|
|
50
54
|
return this;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
deferredEntries() { return this.#deferred.entries(); }
|
|
58
|
+
putDeferred(entry) { return this.#deferred.put(entry); }
|
|
59
|
+
patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
|
|
60
|
+
removeDeferred(id) { return this.#deferred.remove(id); }
|
|
61
|
+
|
|
53
62
|
sessionFor(key) {
|
|
54
63
|
return this.#state.sessions[key] ?? null;
|
|
55
64
|
}
|
|
@@ -138,6 +147,8 @@ export class StateStore {
|
|
|
138
147
|
return [...ids];
|
|
139
148
|
}
|
|
140
149
|
|
|
150
|
+
// ── Deferred delivery (persisted: surviving restarts) ───────────────────
|
|
151
|
+
|
|
141
152
|
// ── Managed Feishu topics (thread_id → root message, persisted) ────────
|
|
142
153
|
|
|
143
154
|
topicRootFor(threadId) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
|
|
1
2
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
2
3
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
3
4
|
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
|
|
@@ -62,6 +63,11 @@ import {
|
|
|
62
63
|
evaluateInboundAccess,
|
|
63
64
|
} from '../shared/inbound-access.mjs';
|
|
64
65
|
import { sendMarkdownReply } from './markdown-reply.mjs';
|
|
66
|
+
import {
|
|
67
|
+
isQqMenuCommand, parseQqMenuCommand, QqMenuStore,
|
|
68
|
+
qqMenuPage, qqMenuView, sendQqMenu,
|
|
69
|
+
} from './qq-menu.mjs';
|
|
70
|
+
import { withSessionBindingLock } from '../shared/session-binding-lock.mjs';
|
|
65
71
|
import { t } from '../shared/i18n.mjs';
|
|
66
72
|
|
|
67
73
|
function interactionResolvedText() {
|
|
@@ -85,6 +91,7 @@ function helpText() {
|
|
|
85
91
|
t('QQ 机器人已连接 DeepSeek Harness。'),
|
|
86
92
|
'',
|
|
87
93
|
t('直接发送文字、图片或文件即可继续当前会话。'),
|
|
94
|
+
t('/menu 或 /m 打开可点击的功能菜单'),
|
|
88
95
|
t('/new 开启一个全新会话'),
|
|
89
96
|
t('/compact 压缩当前会话的较早上下文'),
|
|
90
97
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
@@ -418,6 +425,7 @@ export class QqHarnessBridge {
|
|
|
418
425
|
#ownerUserOpenid;
|
|
419
426
|
#harness;
|
|
420
427
|
#state;
|
|
428
|
+
#deferred;
|
|
421
429
|
#contextEnhancement;
|
|
422
430
|
#accessPolicy;
|
|
423
431
|
#status;
|
|
@@ -435,6 +443,8 @@ export class QqHarnessBridge {
|
|
|
435
443
|
#commandTasks = new Set();
|
|
436
444
|
#approvals;
|
|
437
445
|
#batchInputs = new BatchInputManager();
|
|
446
|
+
#menus = new QqMenuStore();
|
|
447
|
+
#menuTasks = new Map();
|
|
438
448
|
|
|
439
449
|
constructor({
|
|
440
450
|
bot,
|
|
@@ -467,6 +477,9 @@ export class QqHarnessBridge {
|
|
|
467
477
|
this.#logger = logger;
|
|
468
478
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
469
479
|
this.#signal = signal;
|
|
480
|
+
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
481
|
+
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
482
|
+
});
|
|
470
483
|
this.#fetchImpl = fetchImpl;
|
|
471
484
|
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
|
|
472
485
|
this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
|
|
@@ -488,6 +501,15 @@ export class QqHarnessBridge {
|
|
|
488
501
|
const addressed = message.kind !== 'group'
|
|
489
502
|
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
490
503
|
const commandText = safeText(message);
|
|
504
|
+
const menuTextOnly = !hasQqImageAttachments(message) && !hasQqFileAttachments(message)
|
|
505
|
+
&& !qqReplyReference(message);
|
|
506
|
+
const explicitMenu = menuTextOnly && isQqMenuCommand(commandText);
|
|
507
|
+
const numericMenu = menuTextOnly && /^\d{1,2}$/u.test(commandText)
|
|
508
|
+
&& this.#menus.has(key, sender)
|
|
509
|
+
&& !this.#pendingInteractions.has(key) && !this.#approvals.hasPending(key)
|
|
510
|
+
&& this.#batchInputs.status(key).phase === 'idle';
|
|
511
|
+
const menuInput = numericMenu
|
|
512
|
+
? `/m pick ${this.#menus.tokenFor(key, sender)} ${commandText}` : commandText;
|
|
491
513
|
if (addressed) {
|
|
492
514
|
const access = this.#accessPolicy
|
|
493
515
|
? evaluateInboundAccess(this.#accessPolicy, {
|
|
@@ -496,6 +518,7 @@ export class QqHarnessBridge {
|
|
|
496
518
|
text: commandText,
|
|
497
519
|
hasImages: hasQqImageAttachments(message),
|
|
498
520
|
hasFiles: hasQqFileAttachments(message),
|
|
521
|
+
...(explicitMenu || numericMenu ? { isCommand: true } : {}),
|
|
499
522
|
})
|
|
500
523
|
: senderAllowed(message, this.#ownerUserOpenid)
|
|
501
524
|
? { allowed: true, reason: 'legacy-owner' }
|
|
@@ -518,6 +541,9 @@ export class QqHarnessBridge {
|
|
|
518
541
|
const pending = this.#pendingInteractions.get(key);
|
|
519
542
|
const batchCommand = isBatchInputCommand(commandText);
|
|
520
543
|
const batchStatus = this.#batchInputs.status(key);
|
|
544
|
+
// Ordinary prompts and other commands end number selection. A pending
|
|
545
|
+
// question/approval or batch keeps its existing interpretation of digits.
|
|
546
|
+
if (addressed && !explicitMenu && !numericMenu) this.#menus.clear(key, sender);
|
|
521
547
|
if (batchCommand && addressed && message.kind === 'group') {
|
|
522
548
|
return this.#finishBatchResult(
|
|
523
549
|
message,
|
|
@@ -527,6 +553,9 @@ export class QqHarnessBridge {
|
|
|
527
553
|
}
|
|
528
554
|
if (message.kind === 'c2c'
|
|
529
555
|
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
|
556
|
+
if (explicitMenu) return this.#finishBatchResult(message, messageId, {
|
|
557
|
+
message: t('当前正在批量输入,请先发送 /send 或 /cancel,再打开菜单。'),
|
|
558
|
+
});
|
|
530
559
|
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
|
531
560
|
const result = exactBatchStart
|
|
532
561
|
&& batchStatus.phase === 'idle'
|
|
@@ -550,7 +579,9 @@ export class QqHarnessBridge {
|
|
|
550
579
|
return this.#finishBatchResult(message, messageId, result);
|
|
551
580
|
}
|
|
552
581
|
}
|
|
553
|
-
const commandRunner =
|
|
582
|
+
const commandRunner = explicitMenu || numericMenu
|
|
583
|
+
? () => this.#runMenuCommand(message, menuInput, key)
|
|
584
|
+
: isHistoryCommand(commandText) ? runHistoryCommand
|
|
554
585
|
: hasQqFileAttachments(message) ? null : isControlCommand(commandText)
|
|
555
586
|
? runControlCommand
|
|
556
587
|
: (isModelCommand(commandText)
|
|
@@ -665,6 +696,92 @@ export class QqHarnessBridge {
|
|
|
665
696
|
return current;
|
|
666
697
|
}
|
|
667
698
|
|
|
699
|
+
async #deliverDeferredOutcome(entry, outcome) {
|
|
700
|
+
await sendMarkdownReply(this.#bot, entry.target, deferredOutcomeText(outcome), { logger: this.#logger });
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
#menuContext(key) {
|
|
705
|
+
return { workspace: this.#harness.currentWorkspace?.(), sessionId: this.#state.sessionFor(key) };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async #showMenu(message, key, name, pageView = null) {
|
|
709
|
+
const actor = message.senderId;
|
|
710
|
+
const context = this.#menuContext(key);
|
|
711
|
+
const entry = this.#menus.begin(key, actor, context);
|
|
712
|
+
const view = pageView ?? await qqMenuView(name, this.#harness, this.#state, key, {
|
|
713
|
+
signal: this.#signal, busy: this.#queues.has(key)
|
|
714
|
+
|| this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
|
|
715
|
+
});
|
|
716
|
+
this.#signal?.throwIfAborted();
|
|
717
|
+
this.#harness.assertWorkspaceScope?.();
|
|
718
|
+
const current = this.#menuContext(key);
|
|
719
|
+
if (current.workspace !== context.workspace || current.sessionId !== context.sessionId) {
|
|
720
|
+
return { message: t('会话或工作区已变化,请重新发送 /m。') };
|
|
721
|
+
}
|
|
722
|
+
if (!this.#menus.publish(key, actor, entry, view)) return { messages: [] };
|
|
723
|
+
await sendQqMenu(this.#bot, message.replyTarget, view, entry.token, { logger: this.#logger });
|
|
724
|
+
this.#status.messagesReplied += 1;
|
|
725
|
+
this.#status.lastReplyAt = new Date().toISOString();
|
|
726
|
+
return { messages: [] };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async #runMenuCommand(message, text, key) {
|
|
730
|
+
const parsed = isQqMenuCommand(text) ? parseQqMenuCommand(text) : { number: Number(text) };
|
|
731
|
+
if (parsed.error) return { message: t('菜单命令无效,请发送 /m 重新打开。') };
|
|
732
|
+
if (parsed.name) return this.#showMenu(message, key, parsed.name);
|
|
733
|
+
const choice = this.#menus.take(key, message.senderId, parsed.number, parsed.token, this.#menuContext(key));
|
|
734
|
+
if (choice.error) return { message: choice.error };
|
|
735
|
+
const action = choice.action;
|
|
736
|
+
if (action.kind === 'section') return this.#showMenu(message, key, action.name);
|
|
737
|
+
if (action.kind === 'page') return this.#showMenu(message, key, '', qqMenuPage(action.list, action.page));
|
|
738
|
+
if (action.kind === 'archive') {
|
|
739
|
+
await this.#state.setIncludeArchivedSessions(action.include);
|
|
740
|
+
return this.#showMenu(message, key, 'main');
|
|
741
|
+
}
|
|
742
|
+
const command = action.text;
|
|
743
|
+
const pendingInteraction = this.#pendingInteractions.has(key) || this.#approvals.hasPending(key);
|
|
744
|
+
const isBusy = () => this.#queues.has(key) || this.#pendingInteractions.has(key)
|
|
745
|
+
|| this.#approvals.hasPending(key) || this.#batchInputs.status(key).phase !== 'idle';
|
|
746
|
+
const needsIdle = /^\/(?:new|session|workspace|compact)(?:\s|$)/u.test(command);
|
|
747
|
+
if (isBusy() && needsIdle) {
|
|
748
|
+
return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
|
|
749
|
+
}
|
|
750
|
+
const options = { signal: this.#signal, isDirect: message.kind === 'c2c', pendingInteraction,
|
|
751
|
+
control: { owner: this, key }, deferredDelivery: this.#deferred };
|
|
752
|
+
// Existing runners own all Host mutations and control authorization.
|
|
753
|
+
const execute = async () => {
|
|
754
|
+
this.#signal?.throwIfAborted();
|
|
755
|
+
const current = this.#menuContext(key);
|
|
756
|
+
if (current.workspace !== choice.context.workspace || current.sessionId !== choice.context.sessionId) {
|
|
757
|
+
return { message: t('会话或工作区已变化,请重新发送 /m。') };
|
|
758
|
+
}
|
|
759
|
+
// A prompt may have started while this action waited for a prior menu command.
|
|
760
|
+
if (needsIdle && isBusy()) return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
|
|
761
|
+
if (isControlCommand(command)) return runControlCommand(command, this.#harness, this.#state, key, options);
|
|
762
|
+
if (isModelCommand(command)) return runModelCommand(command, this.#harness, this.#state, key, options);
|
|
763
|
+
if (isPresetCommand(command)) return runPresetCommand(command, this.#harness, this.#state, key, options);
|
|
764
|
+
if (command === '/new') return withSessionBindingLock(this.#state, key, async () => {
|
|
765
|
+
if (isBusy()) return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
|
|
766
|
+
const locked = this.#menuContext(key);
|
|
767
|
+
if (locked.workspace !== choice.context.workspace || locked.sessionId !== choice.context.sessionId) {
|
|
768
|
+
return { message: t('会话或工作区已变化,请重新发送 /m。') };
|
|
769
|
+
}
|
|
770
|
+
await this.#state.clearSession(key);
|
|
771
|
+
return { message: t('已开启新会话。请发送你的问题。') };
|
|
772
|
+
});
|
|
773
|
+
if (command === '/compact') return runCompactCommand(command, this.#harness, this.#state, key, options);
|
|
774
|
+
return runWorkspaceCommand(command, this.#harness, key);
|
|
775
|
+
};
|
|
776
|
+
// Stop/steer must stay available while a slower menu mutation is pending.
|
|
777
|
+
if (isControlCommand(command)) return execute();
|
|
778
|
+
const previous = this.#menuTasks.get(key) ?? Promise.resolve();
|
|
779
|
+
const task = previous.catch(() => undefined).then(execute);
|
|
780
|
+
this.#menuTasks.set(key, task);
|
|
781
|
+
try { return await task; }
|
|
782
|
+
finally { if (this.#menuTasks.get(key) === task) this.#menuTasks.delete(key); }
|
|
783
|
+
}
|
|
784
|
+
|
|
668
785
|
async waitForIdle() {
|
|
669
786
|
await Promise.allSettled([
|
|
670
787
|
...this.#queues.values(),
|
|
@@ -674,6 +791,7 @@ export class QqHarnessBridge {
|
|
|
674
791
|
...this.#approvalTasks,
|
|
675
792
|
...this.#commandTasks,
|
|
676
793
|
]);
|
|
794
|
+
await this.#deferred.whenIdle();
|
|
677
795
|
}
|
|
678
796
|
|
|
679
797
|
async #processFastCommand(message, messageId, key, text, runner) {
|
|
@@ -690,6 +808,7 @@ export class QqHarnessBridge {
|
|
|
690
808
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
691
809
|
|| this.#approvals.hasPending(key),
|
|
692
810
|
control: { owner: this, key },
|
|
811
|
+
deferredDelivery: this.#deferred,
|
|
693
812
|
});
|
|
694
813
|
if (result?.stopped) {
|
|
695
814
|
await Promise.allSettled([
|
|
@@ -905,6 +1024,7 @@ export class QqHarnessBridge {
|
|
|
905
1024
|
// redelivery after a failed error notice must never execute it twice.
|
|
906
1025
|
await markMessageSeen();
|
|
907
1026
|
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
|
1027
|
+
deferredDelivery: () => ({ coordinator: this.#deferred, target: { scope: target.scope, targetId: target.targetId } }),
|
|
908
1028
|
harness: this.#harness,
|
|
909
1029
|
state: this.#state,
|
|
910
1030
|
key,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { ApiError } from '@tencent-connect/qqbot-nodejs';
|
|
3
|
+
import { workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
|
4
|
+
import { t } from '../shared/i18n.mjs';
|
|
5
|
+
|
|
6
|
+
export const QQ_MENU_TTL_MS = 15 * 60_000;
|
|
7
|
+
const PAGE_SIZE = 6;
|
|
8
|
+
const MAX_MENUS = 256;
|
|
9
|
+
const SECTIONS = new Set(['main', 'sessions', 'workspaces', 'presets', 'models', 'steer', 'custom', 'status', 'help']);
|
|
10
|
+
const clean = (value, limit = 160) => [...String(value ?? '').replace(/[\p{Cc}\p{Cf}]/gu, ' ')].slice(0, limit).join('');
|
|
11
|
+
const command = (label, text) => ({ label, action: { kind: 'command', text } });
|
|
12
|
+
const section = (label, name) => ({ label, action: { kind: 'section', name } });
|
|
13
|
+
const back = () => section(t('返回主菜单'), 'main');
|
|
14
|
+
|
|
15
|
+
export function isQqMenuCommand(text) {
|
|
16
|
+
return typeof text === 'string' && /^\/(?:m|menu)(?=$|\s)/iu.test(text.trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseQqMenuCommand(text) {
|
|
20
|
+
const match = /^\/(?:m|menu)(?:\s+(\S+))?(?:\s+(\S+))?(?:\s+(\S+))?$/iu.exec(text.trim());
|
|
21
|
+
if (!match) return { error: true };
|
|
22
|
+
const name = match[1]?.toLowerCase() ?? 'main';
|
|
23
|
+
if (SECTIONS.has(name) && !match[2]) return { name };
|
|
24
|
+
if (name === 'pick' && /^[a-f0-9]{24}$/u.test(match[2] ?? '') && /^\d{1,2}$/u.test(match[3] ?? '')) {
|
|
25
|
+
return { token: match[2], number: Number(match[3]) };
|
|
26
|
+
}
|
|
27
|
+
return { error: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A menu belongs to its actor and route. The exact displayed actions, including
|
|
31
|
+
// paginated choices, stay in memory; clients only send an opaque token + index.
|
|
32
|
+
export class QqMenuStore {
|
|
33
|
+
#entries = new Map();
|
|
34
|
+
#now;
|
|
35
|
+
constructor({ now = Date.now } = {}) { this.#now = now; }
|
|
36
|
+
#key(route, actor) { return `${route}\0${actor}`; }
|
|
37
|
+
has(route, actor) { return this.#entries.has(this.#key(route, actor)); }
|
|
38
|
+
tokenFor(route, actor) { return this.#entries.get(this.#key(route, actor))?.token; }
|
|
39
|
+
clear(route, actor) { this.#entries.delete(this.#key(route, actor)); }
|
|
40
|
+
begin(route, actor, context) {
|
|
41
|
+
const key = this.#key(route, actor);
|
|
42
|
+
const entry = { ...context, token: randomBytes(12).toString('hex'),
|
|
43
|
+
expiresAt: this.#now() + QQ_MENU_TTL_MS, used: false, view: null };
|
|
44
|
+
this.#entries.delete(key);
|
|
45
|
+
this.#entries.set(key, entry);
|
|
46
|
+
while (this.#entries.size > MAX_MENUS) this.#entries.delete(this.#entries.keys().next().value);
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
publish(route, actor, entry, view) {
|
|
50
|
+
if (this.#entries.get(this.#key(route, actor)) !== entry) return false;
|
|
51
|
+
entry.view = view;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
take(route, actor, number, token, context) {
|
|
55
|
+
const entry = this.#entries.get(this.#key(route, actor));
|
|
56
|
+
if (!entry || entry.used || !entry.view || entry.expiresAt <= this.#now()
|
|
57
|
+
|| (token && token !== entry.token)) return { error: t('这个菜单已过期,请回复 /m 重新打开。') };
|
|
58
|
+
if (entry.workspace !== context.workspace || entry.sessionId !== context.sessionId) {
|
|
59
|
+
entry.used = true;
|
|
60
|
+
return { error: t('会话或工作区已变化,请重新发送 /m。') };
|
|
61
|
+
}
|
|
62
|
+
const choice = entry.view.entries[number - 1];
|
|
63
|
+
if (!Number.isSafeInteger(number) || number < 1 || !choice) {
|
|
64
|
+
return { error: t('菜单没有这个编号,回复 /m 重新打开。') };
|
|
65
|
+
}
|
|
66
|
+
// Claim before awaiting anything: retries cannot repeat a destructive action.
|
|
67
|
+
entry.used = true;
|
|
68
|
+
return { action: choice.action, context: entry };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function qqMenuPage(list, requestedPage = 0) {
|
|
73
|
+
const pages = Math.max(1, Math.ceil(list.choices.length / PAGE_SIZE));
|
|
74
|
+
const page = Math.min(Math.max(0, requestedPage), pages - 1);
|
|
75
|
+
const entries = list.choices.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
76
|
+
if (page > 0) entries.push({ label: t('上一页'), action: { kind: 'page', list, page: page - 1 } });
|
|
77
|
+
if (page + 1 < pages) entries.push({ label: t('下一页'), action: { kind: 'page', list, page: page + 1 } });
|
|
78
|
+
entries.push(back());
|
|
79
|
+
return { title: list.title, detail: [list.detail, t('第 {page} / {pages} 页', { page: page + 1, pages }),
|
|
80
|
+
list.choices.length ? '' : t('暂无可用选项。')].filter(Boolean).join('\n'), entries, columns: 2 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function catalogFor(harness, sessionId, options) {
|
|
84
|
+
const session = sessionId ? harness.workspaceSession?.(sessionId) : null;
|
|
85
|
+
return typeof session?.models === 'function' ? session.models(options) : harness.listModels(options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function qqMenuView(name, harness, state, key, { signal, busy = false } = {}) {
|
|
89
|
+
const workspace = harness.currentWorkspace?.();
|
|
90
|
+
const sessionId = state.sessionFor(key);
|
|
91
|
+
const options = { signal };
|
|
92
|
+
const archived = state.includesArchivedSessions?.() === true;
|
|
93
|
+
const visibleSessions = (listed) => (listed?.sessions ?? []).filter((item) => archived || item.archived !== true);
|
|
94
|
+
if (name === 'main' || name === 'status') {
|
|
95
|
+
if (name === 'status') await harness.ensureRunning(options);
|
|
96
|
+
const results = await Promise.allSettled([
|
|
97
|
+
harness.listWorkspaceSessions?.(workspace, options),
|
|
98
|
+
catalogFor(harness, sessionId, options),
|
|
99
|
+
harness.agentPresetSettings?.(options),
|
|
100
|
+
]);
|
|
101
|
+
signal?.throwIfAborted();
|
|
102
|
+
const [listed, catalog, settings] = results.map((r) => r.status === 'fulfilled' ? r.value : null);
|
|
103
|
+
const bound = listed?.sessions?.find((item) => item.sessionId === sessionId);
|
|
104
|
+
const presetId = settings?.agentPreset ?? settings?.agentPresetCatalog?.defaultId;
|
|
105
|
+
const preset = settings?.agentPresetCatalog?.items?.find((item) => item.id === presetId);
|
|
106
|
+
const model = catalog?.groups?.find((group) => group.id === catalog.current?.provider)
|
|
107
|
+
?.models?.find((item) => item.id === catalog.current?.model);
|
|
108
|
+
const detail = [
|
|
109
|
+
name === 'status' ? t('QQ 机器人与 DeepSeek Harness 连接正常。') : '',
|
|
110
|
+
t('工作区:{workspace}', { workspace: clean(workspace) || t('未设置') }),
|
|
111
|
+
t('当前会话:{session}', { session: clean(bound?.title || sessionId) || t('新会话') }),
|
|
112
|
+
t('模式/预设:{preset}', { preset: clean(preset?.label || presetId) || t('跟随 Host 默认') }),
|
|
113
|
+
t('当前模型:{model}', { model: clean(model?.name || catalog?.current?.model) || t('暂不可用') }),
|
|
114
|
+
t('归档会话:{state}', { state: archived ? t('显示') : t('隐藏') }),
|
|
115
|
+
t('任务状态:{state}', { state: busy ? t('处理中') : t('空闲') }),
|
|
116
|
+
results.some((r) => r.status === 'rejected') ? t('部分设置暂未加载,可进入对应列表重试。') : '',
|
|
117
|
+
].filter(Boolean).join('\n');
|
|
118
|
+
return { title: name === 'main' ? t('🤖 助手中心') : t('📊 状态'), detail,
|
|
119
|
+
columns: 3, entries: name === 'status' ? [back()] : [
|
|
120
|
+
section(t('💬 会话选择'), 'sessions'), section(t('📁 工作区'), 'workspaces'), section(t('🤖 模式/预设'), 'presets'),
|
|
121
|
+
section(t('🧠 模型'), 'models'), command(t('🆕 新会话'), '/new'), section(t('📋 会话列表'), 'sessions'),
|
|
122
|
+
command(t('⏹ 停止'), '/stop'), command(t('📐 压缩'), '/compact'), section(t('💬 补充指令'), 'steer'),
|
|
123
|
+
{ label: t('切换归档显示'), action: { kind: 'archive', include: !archived } },
|
|
124
|
+
section(t('📊 状态'), 'status'), section(t('📖 帮助'), 'help'),
|
|
125
|
+
] };
|
|
126
|
+
}
|
|
127
|
+
if (name === 'sessions') {
|
|
128
|
+
const listed = await harness.listWorkspaceSessions(workspace, options);
|
|
129
|
+
return qqMenuPage({ title: t('📋 会话列表'), detail: clean(workspace), choices: visibleSessions(listed).map((item) =>
|
|
130
|
+
command(`${item.sessionId === sessionId ? '✓ ' : ''}${clean(item.title || item.sessionId, 70)}${item.archived ? t('(已归档)') : ''}`, `/session ${item.sessionId}`)) });
|
|
131
|
+
}
|
|
132
|
+
if (name === 'workspaces') {
|
|
133
|
+
const { paths } = await workspacePathSnapshot(harness, options);
|
|
134
|
+
return qqMenuPage({ title: t('📁 工作区'), choices: paths.map((path) =>
|
|
135
|
+
command(`${path === workspace ? '✓ ' : ''}${clean(path)}`, `/workspace ${path}`)) });
|
|
136
|
+
}
|
|
137
|
+
if (name === 'models') {
|
|
138
|
+
const catalog = await catalogFor(harness, sessionId, options);
|
|
139
|
+
return qqMenuPage({ title: t('🧠 模型'), detail: catalog.failures?.length ? t('部分模型暂不可用,可稍后重试。') : '',
|
|
140
|
+
choices: catalog.groups.flatMap((group) => group.models.map((model) => command(
|
|
141
|
+
`${catalog.current?.provider === group.id && catalog.current?.model === model.id ? '✓ ' : ''}${clean(group.name, 30)} · ${clean(model.name, 60)}`,
|
|
142
|
+
`/model ${group.id}/${model.id}`))) });
|
|
143
|
+
}
|
|
144
|
+
if (name === 'presets') {
|
|
145
|
+
const settings = await harness.agentPresetSettings(options);
|
|
146
|
+
return qqMenuPage({ title: t('🤖 模式/预设'), detail: t('预设仅用于之后的新会话。'), choices: [
|
|
147
|
+
command(`${settings.agentPreset === null ? '✓ ' : ''}${t('跟随 Host 默认')}`, '/preset --default'),
|
|
148
|
+
...settings.agentPresetCatalog.items.map((item) => command(
|
|
149
|
+
`${settings.agentPreset === item.id ? '✓ ' : ''}${clean(item.label || item.id, 70)}`,
|
|
150
|
+
`/preset ${/^\d+$/u.test(item.id) ? 'id:' : ''}${item.id}`)),
|
|
151
|
+
] });
|
|
152
|
+
}
|
|
153
|
+
if (name === 'steer') return { title: t('💬 补充指令'), columns: 2,
|
|
154
|
+
detail: t('给当前正在运行的任务补充指令。'), entries: [
|
|
155
|
+
...['继续', '加速运行', '总结当前进展', '更简洁些', '更详细些'].map((text) => command(t(text), `/steer ${t(text)}`)),
|
|
156
|
+
section(t('自定义指令'), 'custom'), back(),
|
|
157
|
+
] };
|
|
158
|
+
if (name === 'custom') return { title: t('自定义指令'), detail: t('请发送 /steer 你的补充指令。'), entries: [back()] };
|
|
159
|
+
return { title: t('📖 帮助'), detail: [
|
|
160
|
+
t('发送 /m 打开助手中心,点击按钮或回复当前列表的数字选择。'),
|
|
161
|
+
t('设置:会话、工作区、模式/预设、模型。'),
|
|
162
|
+
t('会话:新会话、会话列表、归档显示切换。'),
|
|
163
|
+
t('任务:停止、压缩、快捷或自定义补充指令。'),
|
|
164
|
+
t('信息:状态、帮助。'),
|
|
165
|
+
t('菜单有效期为 15 分钟;发送普通消息会退出数字选择。'),
|
|
166
|
+
t('回答问题、审批和批量输入时,数字优先用于当前交互。'),
|
|
167
|
+
t('完整命令说明:/help'),
|
|
168
|
+
].join('\n'), entries: [back()] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function qqMenuText(view, { fallback = false } = {}) {
|
|
172
|
+
return [view.title, view.detail, '', ...view.entries.map((entry, index) => `${index + 1}. ${entry.label}`), '',
|
|
173
|
+
fallback ? t('当前无法展示按钮,请回复数字选择。') : t('点击按钮或回复数字选择,/m 返回主菜单。'),
|
|
174
|
+
t('回答问题、审批和批量输入时,请先完成当前交互。'),
|
|
175
|
+
].filter((line) => line !== undefined).join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function qqMenuKeyboard(view, token) {
|
|
179
|
+
const buttons = view.entries.map((entry, index) => ({
|
|
180
|
+
id: `${token}_${index + 1}`,
|
|
181
|
+
render_data: { label: view.columns === 3
|
|
182
|
+
? clean(entry.label, 24).replace(t('模式/预设'), t('预设')).replace(t('切换归档显示'), t('归档显示'))
|
|
183
|
+
: `${index + 1}. ${clean(entry.label, 32)}`, visited_label: t('已选择'), style: 0 },
|
|
184
|
+
action: { type: 2, permission: { type: 2 }, data: `/m pick ${token} ${index + 1}`, enter: true,
|
|
185
|
+
unsupport_tips: t('请回复数字选择,或发送 /m 重新打开菜单。') },
|
|
186
|
+
}));
|
|
187
|
+
const rows = [];
|
|
188
|
+
for (let i = 0; i < buttons.length; i += view.columns ?? 2) rows.push({ buttons: buttons.slice(i, i + (view.columns ?? 2)) });
|
|
189
|
+
return { content: { rows } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function sendQqMenu(bot, target, view, token, { logger = console } = {}) {
|
|
193
|
+
if (typeof bot.send === 'function') {
|
|
194
|
+
try {
|
|
195
|
+
const content = qqMenuText(view).replace(/[\\`*_{}\[\]()<>#|!]/gu, '\\$&');
|
|
196
|
+
return await bot.send({ target, msgType: 2, markdown: { content }, keyboard: qqMenuKeyboard(view, token) });
|
|
197
|
+
} catch (error) {
|
|
198
|
+
// A timeout may have delivered already. Only a definite provider rejection
|
|
199
|
+
// permits another send, so a network fault never creates duplicate menus.
|
|
200
|
+
if (!(error instanceof ApiError) || error.httpStatus < 400 || error.httpStatus >= 500 || error.httpStatus === 429) throw error;
|
|
201
|
+
logger.warn?.('[dsh-im:qq] menu rejected; using numbered text:', error.bizCode);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return bot.sendText(target, qqMenuText(view, { fallback: true }));
|
|
205
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
1
2
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
3
4
|
|
|
@@ -16,6 +17,8 @@ function normalizeState(value) {
|
|
|
16
17
|
return {
|
|
17
18
|
version: 1,
|
|
18
19
|
sessions,
|
|
20
|
+
includeArchivedSessions: value.includeArchivedSessions === true,
|
|
21
|
+
...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
|
|
19
22
|
seenMessageIds: Array.isArray(value.seenMessageIds)
|
|
20
23
|
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
|
21
24
|
: [],
|
|
@@ -26,6 +29,7 @@ export class QqStateStore {
|
|
|
26
29
|
#path;
|
|
27
30
|
#state = structuredClone(EMPTY_STATE);
|
|
28
31
|
#writeQueue = Promise.resolve();
|
|
32
|
+
#deferred = deferredStateAccess(() => this.#state, () => this.#persist());
|
|
29
33
|
|
|
30
34
|
constructor(path) {
|
|
31
35
|
this.#path = path;
|
|
@@ -42,6 +46,18 @@ export class QqStateStore {
|
|
|
42
46
|
return this;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
deferredEntries() { return this.#deferred.entries(); }
|
|
50
|
+
putDeferred(entry) { return this.#deferred.put(entry); }
|
|
51
|
+
patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
|
|
52
|
+
removeDeferred(id) { return this.#deferred.remove(id); }
|
|
53
|
+
|
|
54
|
+
includesArchivedSessions() { return this.#state.includeArchivedSessions === true; }
|
|
55
|
+
|
|
56
|
+
async setIncludeArchivedSessions(include) {
|
|
57
|
+
this.#state.includeArchivedSessions = include === true;
|
|
58
|
+
await this.#persist();
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
sessionFor(key) {
|
|
46
62
|
return this.#state.sessions[key] ?? null;
|
|
47
63
|
}
|
|
@@ -161,7 +161,20 @@ function normalizeDeliveryTargets(value, { version } = {}) {
|
|
|
161
161
|
if (!targets || typeof targets !== 'object' || Array.isArray(targets)) return null;
|
|
162
162
|
const normalizedTargets = Object.create(null);
|
|
163
163
|
for (const [targetId, target] of Object.entries(targets)) {
|
|
164
|
-
|
|
164
|
+
// Backward compatibility: some released builds persisted the target id
|
|
165
|
+
// inside the stored object as well. Accept a redundant targetId that
|
|
166
|
+
// matches the map key when loading a stored document; a mismatch stays
|
|
167
|
+
// invalid so a corrupted file still fails closed.
|
|
168
|
+
let candidate = target;
|
|
169
|
+
if (target && typeof target === 'object' && !Array.isArray(target)
|
|
170
|
+
&& target.targetId !== undefined) {
|
|
171
|
+
if (target.targetId !== targetId) {
|
|
172
|
+
throw deliveryTargetError('invalid-target', 'Invalid target id');
|
|
173
|
+
}
|
|
174
|
+
const { targetId: _legacyTargetId, ...withoutLegacyId } = target;
|
|
175
|
+
candidate = withoutLegacyId;
|
|
176
|
+
}
|
|
177
|
+
const normalized = normalizeDeliveryTarget(candidate, {
|
|
165
178
|
targetId,
|
|
166
179
|
allowSessionSync: version >= CURRENT_DOCUMENT_VERSION,
|
|
167
180
|
});
|
|
@@ -1436,6 +1449,9 @@ export function createBotWorkspaceScope(
|
|
|
1436
1449
|
stopActiveTurn(...args) {
|
|
1437
1450
|
return invokeStartedSessionMutation('stopActiveTurn', args, 'turn stop');
|
|
1438
1451
|
},
|
|
1452
|
+
stopDeferredTurn(...args) {
|
|
1453
|
+
return invokeStartedSessionMutation('stopDeferredTurn', args, 'deferred turn stop');
|
|
1454
|
+
},
|
|
1439
1455
|
steerActiveTurn(...args) {
|
|
1440
1456
|
return invokeStartedSessionMutation('steerActiveTurn', args, 'turn steering');
|
|
1441
1457
|
},
|
|
@@ -40,6 +40,7 @@ export async function runControlCommand(text, harness, state, key, {
|
|
|
40
40
|
hasImages = false,
|
|
41
41
|
pendingInteraction = false,
|
|
42
42
|
control,
|
|
43
|
+
deferredDelivery,
|
|
43
44
|
} = {}) {
|
|
44
45
|
if (!isControlCommand(text)) return null;
|
|
45
46
|
const command = text.trim();
|
|
@@ -62,6 +63,13 @@ export async function runControlCommand(text, harness, state, key, {
|
|
|
62
63
|
throw new TypeError('Harness session does not support stopping active turns');
|
|
63
64
|
}
|
|
64
65
|
const stopped = await session.stopActiveTurn(control, requestOptions(signal));
|
|
66
|
+
if (!stopped && deferredDelivery) {
|
|
67
|
+
const background = await deferredDelivery.stop(key);
|
|
68
|
+
if (background === 'stopped') return commandResult(t('已请求停止后台任务。'), { stopped: true });
|
|
69
|
+
if (background === 'unavailable') {
|
|
70
|
+
return commandResult(t('无法安全停止后台任务,请在 Harness 中查看并停止对应任务。'));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
65
73
|
return stopped
|
|
66
74
|
? commandResult(t('已请求停止当前任务。'), { stopped: true })
|
|
67
75
|
: commandResult(t('当前聊天没有正在运行的任务。'));
|