@xmanrui/dsh-im 4.12.0 → 4.13.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.
Files changed (38) hide show
  1. package/PROACTIVE_DELIVERY.en.md +4 -0
  2. package/PROACTIVE_DELIVERY.md +4 -0
  3. package/README.en.md +11 -2
  4. package/README.md +11 -2
  5. package/assets/dingtalk-menu-template.json +6 -0
  6. package/lib/client.js +3 -2
  7. package/lib/index.js +268 -263
  8. package/package.json +1 -1
  9. package/plugin-src/client/styles.js +2 -1
  10. package/plugin-src/host/harness-command-executor.mjs +13 -6
  11. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  12. package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
  13. package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
  14. package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
  15. package/src/channels/dingtalk/state-store.mjs +8 -0
  16. package/src/channels/feishu/bridge.mjs +74 -3
  17. package/src/channels/feishu/state-store.mjs +11 -0
  18. package/src/channels/qq/qq-bridge.mjs +121 -1
  19. package/src/channels/qq/qq-menu.mjs +205 -0
  20. package/src/channels/qq/state-store.mjs +16 -0
  21. package/src/channels/shared/bot-workspace-store.mjs +3 -0
  22. package/src/channels/shared/control-command.mjs +8 -0
  23. package/src/channels/shared/conversation-state-store.mjs +8 -0
  24. package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
  25. package/src/channels/shared/deferred-delivery.mjs +161 -0
  26. package/src/channels/shared/deferred-state.mjs +53 -0
  27. package/src/channels/shared/harness-client.mjs +21 -1
  28. package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
  29. package/src/channels/shared/i18n-en/feishu.mjs +6 -0
  30. package/src/channels/shared/i18n-en/qq.mjs +39 -0
  31. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  32. package/src/channels/shared/text-harness-bridge.mjs +15 -0
  33. package/src/channels/shared/workspace-session.mjs +16 -1
  34. package/src/channels/wecom/state-store.mjs +8 -0
  35. package/src/channels/wecom/wecom-bridge.mjs +19 -4
  36. package/src/channels/weixin/state-store.mjs +64 -1
  37. package/src/channels/weixin/weixin-bridge.mjs +31 -5
  38. package/src/channels/weixin/weixin-runtime.mjs +33 -8
@@ -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 = isHistoryCommand(commandText) ? runHistoryCommand
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
  }
@@ -1436,6 +1436,9 @@ export function createBotWorkspaceScope(
1436
1436
  stopActiveTurn(...args) {
1437
1437
  return invokeStartedSessionMutation('stopActiveTurn', args, 'turn stop');
1438
1438
  },
1439
+ stopDeferredTurn(...args) {
1440
+ return invokeStartedSessionMutation('stopDeferredTurn', args, 'deferred turn stop');
1441
+ },
1439
1442
  steerActiveTurn(...args) {
1440
1443
  return invokeStartedSessionMutation('steerActiveTurn', args, 'turn steering');
1441
1444
  },
@@ -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('当前聊天没有正在运行的任务。'));
@@ -1,3 +1,4 @@
1
+ import { deferredStateAccess, normalizeDeferredState } from './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,7 @@ function normalizeState(value) {
16
17
  return {
17
18
  version: 1,
18
19
  sessions,
20
+ ...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
19
21
  seenMessageIds: Array.isArray(value.seenMessageIds)
20
22
  ? value.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-1_000)
21
23
  : [],
@@ -27,6 +29,7 @@ export class ConversationStateStore {
27
29
  #path;
28
30
  #state = structuredClone(EMPTY_STATE);
29
31
  #writeQueue = Promise.resolve();
32
+ #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
30
33
 
31
34
  constructor(path) {
32
35
  this.#path = path;
@@ -43,6 +46,11 @@ export class ConversationStateStore {
43
46
  return this;
44
47
  }
45
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
+
46
54
  sessionFor(key) {
47
55
  return this.#state.sessions[key] ?? null;
48
56
  }