@xmanrui/dsh-im 4.11.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 (51) hide show
  1. package/PROACTIVE_DELIVERY.en.md +4 -0
  2. package/PROACTIVE_DELIVERY.md +4 -0
  3. package/README.en.md +12 -2
  4. package/README.md +12 -2
  5. package/assets/dingtalk-menu-template.json +6 -0
  6. package/lib/client.js +1211 -1034
  7. package/lib/index.js +270 -264
  8. package/package.json +1 -1
  9. package/plugin-src/client/channels/dingtalk/index.js +16 -5
  10. package/plugin-src/client/channels/feishu/index.js +45 -36
  11. package/plugin-src/client/channels/qq/index.js +16 -5
  12. package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
  13. package/plugin-src/client/channels/shared/token-channel.js +39 -28
  14. package/plugin-src/client/channels/wecom/index.js +16 -5
  15. package/plugin-src/client/channels/weixin/index.js +16 -5
  16. package/plugin-src/client/channels/whatsapp/index.js +16 -5
  17. package/plugin-src/client/i18n.js +2 -0
  18. package/plugin-src/client/styles.js +14 -1
  19. package/plugin-src/host/harness-command-executor.mjs +13 -6
  20. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
  22. package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
  23. package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
  24. package/src/channels/dingtalk/state-store.mjs +8 -0
  25. package/src/channels/feishu/bridge.mjs +74 -3
  26. package/src/channels/feishu/state-store.mjs +11 -0
  27. package/src/channels/qq/qq-bridge.mjs +121 -1
  28. package/src/channels/qq/qq-menu.mjs +205 -0
  29. package/src/channels/qq/state-store.mjs +16 -0
  30. package/src/channels/shared/bot-workspace-store.mjs +3 -0
  31. package/src/channels/shared/control-command.mjs +8 -0
  32. package/src/channels/shared/conversation-state-store.mjs +8 -0
  33. package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
  34. package/src/channels/shared/deferred-delivery.mjs +161 -0
  35. package/src/channels/shared/deferred-state.mjs +53 -0
  36. package/src/channels/shared/harness-client.mjs +21 -1
  37. package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
  38. package/src/channels/shared/i18n-en/feishu.mjs +6 -0
  39. package/src/channels/shared/i18n-en/qq.mjs +39 -0
  40. package/src/channels/shared/i18n-en/wecom.mjs +24 -0
  41. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  42. package/src/channels/shared/text-harness-bridge.mjs +15 -0
  43. package/src/channels/shared/workspace-session.mjs +16 -1
  44. package/src/channels/wecom/state-store.mjs +8 -0
  45. package/src/channels/wecom/wecom-bridge.mjs +294 -6
  46. package/src/channels/wecom/wecom-cards.mjs +101 -0
  47. package/src/channels/wecom/wecom-runtime.mjs +2 -0
  48. package/src/channels/weixin/state-store.mjs +64 -1
  49. package/src/channels/weixin/weixin-bridge.mjs +31 -5
  50. package/src/channels/weixin/weixin-runtime.mjs +33 -8
  51. package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
@@ -15,6 +15,7 @@ function workspaceSession(harness, sessionId) {
15
15
  isRunning: (...args) => harness.isSessionRunning(sessionId, ...args),
16
16
  hasActiveTurn: (...args) => harness.hasActiveTurn(sessionId, ...args),
17
17
  stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
18
+ stopDeferredTurn: (...args) => harness.stopDeferredTurn?.(sessionId, ...args) ?? false,
18
19
  steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
19
20
  ask: (...args) => harness.ask(sessionId, ...args),
20
21
  };
@@ -51,6 +52,7 @@ export async function askInWorkspaceSession({
51
52
  createOptions,
52
53
  existsOptions,
53
54
  askOptions,
55
+ deferredDelivery,
54
56
  }) {
55
57
  const initialTitle = contextEnhanced
56
58
  ? initialSessionTitle({
@@ -97,7 +99,20 @@ export async function askInWorkspaceSession({
97
99
  artifacts.push(artifact);
98
100
  await originalOnArtifact?.(artifact);
99
101
  };
100
- const answer = await binding.session.ask(content ?? text, artifactOptions);
102
+ let answer;
103
+ try {
104
+ answer = await binding.session.ask(content ?? text, artifactOptions);
105
+ } catch (error) {
106
+ if (error?.code === 'harness-reply-timeout' && deferredDelivery) {
107
+ try {
108
+ const { coordinator, ...destination } = deferredDelivery();
109
+ await coordinator.trackTimeout(error, { ...destination, key, sessionId: binding.sessionId });
110
+ } catch (registrationError) {
111
+ console.warn('[dsh-im] unable to persist deferred delivery:', registrationError.message);
112
+ }
113
+ }
114
+ throw error;
115
+ }
101
116
  return {
102
117
  sessionId: binding.sessionId,
103
118
  answer,
@@ -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
 
@@ -14,6 +15,7 @@ function normalizeState(value) {
14
15
  return {
15
16
  version: 1,
16
17
  sessions,
18
+ ...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
17
19
  seenMessageIds: Array.isArray(value.seenMessageIds)
18
20
  ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
19
21
  : [],
@@ -24,6 +26,7 @@ export class WecomStateStore {
24
26
  #path;
25
27
  #state = structuredClone(EMPTY_STATE);
26
28
  #writeQueue = Promise.resolve();
29
+ #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
27
30
 
28
31
  constructor(path) {
29
32
  this.#path = path;
@@ -40,6 +43,11 @@ export class WecomStateStore {
40
43
  return this;
41
44
  }
42
45
 
46
+ deferredEntries() { return this.#deferred.entries(); }
47
+ putDeferred(entry) { return this.#deferred.put(entry); }
48
+ patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
49
+ removeDeferred(id) { return this.#deferred.remove(id); }
50
+
43
51
  sessionFor(key) {
44
52
  return this.#state.sessions[key] ?? null;
45
53
  }
@@ -1,4 +1,9 @@
1
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
1
2
  import { generateReqId } from '@wecom/aibot-node-sdk';
3
+ import { randomUUID } from 'node:crypto';
4
+ import {
5
+ parseWecomMenu, wecomList, wecomMenu, wecomMenuText, wecomSettings, wecomTemplateCard,
6
+ } from './wecom-cards.mjs';
2
7
  import {
3
8
  harnessAnswerForQuestion,
4
9
  harnessQuestionText,
@@ -25,7 +30,7 @@ import {
25
30
  isPresetCommand,
26
31
  runPresetCommand,
27
32
  } from '../shared/preset-command.mjs';
28
- import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
33
+ import { runWorkspaceCommand, workspacePathSnapshot } from '../shared/workspace-command.mjs';
29
34
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
30
35
  import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
31
36
  import {
@@ -68,6 +73,7 @@ function helpText() {
68
73
  t('企业微信机器人已连接 DeepSeek Harness。'),
69
74
  '',
70
75
  t('直接发送文字、图片或文件即可继续当前会话。'),
76
+ t('/menu 或 /m 打开可点击的功能菜单'),
71
77
  t('/new 开启一个全新会话'),
72
78
  t('/compact 压缩当前会话的较早上下文'),
73
79
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
@@ -352,15 +358,17 @@ function thinkingProgressText(update) {
352
358
  }
353
359
 
354
360
  function streamContent(thinkingText, answerText = '', { finish = false } = {}) {
361
+ const answer = String(answerText ?? '').trim();
362
+ // Progress is transient: the final frame must contain only the delivered text.
363
+ if (finish) return answer;
364
+
355
365
  const thinking = String(thinkingText ?? '')
356
366
  .replace(/<\/?think>/gi, '')
357
367
  .trim();
358
- const answer = String(answerText ?? '').trim();
359
368
  if (!thinking) return answer;
360
- const thinkBlock = finish || answer
361
- ? `<think>${thinking}</think>`
369
+ return answer
370
+ ? `<think>${thinking}</think>\n${answer}`
362
371
  : `<think>${thinking}`;
363
- return answer ? `${thinkBlock}\n${answer}` : thinkBlock;
364
372
  }
365
373
 
366
374
  function artifactFailureText(fileName, error) {
@@ -536,6 +544,7 @@ export class WecomHarnessBridge {
536
544
  #client;
537
545
  #harness;
538
546
  #state;
547
+ #deferred;
539
548
  #contextEnhancement;
540
549
  #accessPolicy;
541
550
  #status;
@@ -554,6 +563,9 @@ export class WecomHarnessBridge {
554
563
  #approvals;
555
564
  #batchInputs = new BatchInputManager();
556
565
  #prefetchedImageCount = 0;
566
+ #menus = new Map();
567
+ #eventIds = new Set();
568
+ #cardFrames = new WeakSet();
557
569
 
558
570
  constructor({
559
571
  client,
@@ -586,6 +598,9 @@ export class WecomHarnessBridge {
586
598
  this.#generateReqId = generateStreamId;
587
599
  this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
588
600
  this.#signal = signal;
601
+ this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
602
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
603
+ });
589
604
  this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
590
605
  }
591
606
 
@@ -593,6 +608,264 @@ export class WecomHarnessBridge {
593
608
  return structuredClone(this.#status);
594
609
  }
595
610
 
611
+ #mainMenu() {
612
+ return wecomMenu({
613
+ workspace: this.#harness.currentWorkspace?.(),
614
+ workspaces: [[t('更多选项…'), '/menu workspaces']],
615
+ });
616
+ }
617
+
618
+ async #showMain(frame, { welcome = false } = {}) {
619
+ const key = conversationKey(frame);
620
+ const workspace = this.#harness.currentWorkspace?.();
621
+ const sessionId = this.#state.sessionFor(key);
622
+ // The welcome reply has a five-second deadline. Show immediate controls,
623
+ // then load the selectors independently of that reply window.
624
+ if (welcome) await this.#sendMenu(frame, this.#mainMenu(), { welcome: true });
625
+ const options = { signal: this.#signal };
626
+ const settled = await Promise.allSettled([
627
+ workspacePathSnapshot(this.#harness, options),
628
+ this.#harness.listWorkspaceSessions?.(workspace, options),
629
+ (async () => {
630
+ const session = sessionId ? this.#harness.workspaceSession?.(sessionId) : null;
631
+ return typeof session?.models === 'function'
632
+ ? session.models(options) : this.#harness.listModels?.(options);
633
+ })(),
634
+ this.#harness.agentPresetSettings?.(options),
635
+ ]);
636
+ this.#signal?.throwIfAborted();
637
+ const [paths, listed, catalog, settings] = settled.map((result) => result.status === 'fulfilled' ? result.value : null);
638
+ const sessions = (listed?.sessions ?? []).map((item) => [item.title || t('暂无标题'), `/session ${item.sessionId}`]);
639
+ const models = (catalog?.groups ?? []).flatMap((group) => group.models.map((model) => [
640
+ `${model.name} (${group.id})`, `/model ${group.id}/${model.id}`,
641
+ ]));
642
+ const presets = [[t('跟随 Host 默认'), '/preset --default'], ...(settings?.agentPresetCatalog?.items ?? []).map((item) => [
643
+ item.label || item.id, `/preset ${/^\d+$/u.test(item.id) ? 'id:' : ''}${item.id}`,
644
+ ])];
645
+ const currentPreset = settings?.agentPreset;
646
+ const settingsMenu = wecomSettings({
647
+ sessions, models, presets, sessionId,
648
+ currentModel: catalog?.current ? `/model ${catalog.current.provider}/${catalog.current.model}` : null,
649
+ currentPreset: currentPreset ? `/preset ${/^\d+$/u.test(currentPreset) ? 'id:' : ''}${currentPreset}` : '/preset --default',
650
+ presetLabel: settings?.agentPresetCatalog?.items.find((item) => item.id === currentPreset)?.label,
651
+ });
652
+ await this.#sendMenu(frame, settingsMenu, { active: welcome });
653
+ if (welcome) return;
654
+ await this.#sendMenu(frame, wecomMenu({ workspace,
655
+ workspaces: (paths?.paths ?? (workspace ? [workspace] : [])).map((path) => [path, `/workspace ${path}`]),
656
+ }), { active: true });
657
+ }
658
+
659
+ #rememberMenu(frame, menu) {
660
+ const taskId = `menu_${randomUUID()}`;
661
+ const now = Date.now();
662
+ for (const [id, entry] of this.#menus) {
663
+ if (entry.expiresAt <= now) this.#menus.delete(id);
664
+ }
665
+ while (this.#menus.size >= 256) this.#menus.delete(this.#menus.keys().next().value);
666
+ this.#menus.set(taskId, {
667
+ menu, key: conversationKey(frame),
668
+ workspace: this.#harness.currentWorkspace?.(), expiresAt: now + 30 * 60_000,
669
+ });
670
+ return wecomTemplateCard(menu, taskId);
671
+ }
672
+
673
+ async #sendMenu(frame, menu, { welcome = false, active = false } = {}) {
674
+ this.#signal?.throwIfAborted();
675
+ const body = bodyOf(frame);
676
+ const chatId = body.chattype === 'group' ? body.chatid : body.from.userid;
677
+ const card = this.#rememberMenu(frame, menu);
678
+ try {
679
+ if (welcome) {
680
+ await this.#client.replyWelcome(frame, { msgtype: 'template_card', template_card: card });
681
+ } else if (active || this.#cardFrames.has(frame)) {
682
+ await this.#client.sendMessage(chatId, { msgtype: 'template_card', template_card: card });
683
+ } else {
684
+ await this.#client.replyTemplateCard(frame, card);
685
+ }
686
+ } catch (error) {
687
+ this.#menus.delete(card.task_id);
688
+ this.#logger.warn?.('[dsh-im:wecom] menu delivery failed; using text:', error);
689
+ if (welcome) {
690
+ await this.#client.replyWelcome(frame, { msgtype: 'text', text: { content: wecomMenuText(menu) } });
691
+ } else {
692
+ await this.#sendImmediate(frame, chatId, wecomMenuText(menu));
693
+ }
694
+ }
695
+ }
696
+
697
+ acceptEvent(frame) {
698
+ const body = bodyOf(frame);
699
+ const id = nonEmptyString(body.msgid);
700
+ if (this.#signal?.aborted || !id || this.#eventIds.has(id) || this.#state.hasSeen(id)) {
701
+ return Promise.resolve();
702
+ }
703
+ this.#eventIds.add(id);
704
+ let task;
705
+ task = this.#processEvent(frame).catch((error) => {
706
+ if (this.#signal?.aborted) return;
707
+ this.#status.lastError = error?.message ?? String(error);
708
+ this.#logger.warn?.('[dsh-im:wecom] menu event failed:', error);
709
+ }).finally(() => {
710
+ this.#eventIds.delete(id);
711
+ this.#commandTasks.delete(task);
712
+ });
713
+ this.#commandTasks.add(task);
714
+ return task;
715
+ }
716
+
717
+ async #processEvent(frame) {
718
+ const body = bodyOf(frame);
719
+ const senderId = nonEmptyString(body.from?.userid);
720
+ const type = body.event?.eventtype;
721
+ if (!senderId || !['enter_chat', 'template_card_event'].includes(type)) return;
722
+ const chattype = body.chattype ?? (body.chatid ? 'group' : 'single');
723
+ if (!['single', 'group'].includes(chattype) || (chattype === 'group' && !body.chatid)) return;
724
+ const normalized = { ...frame, body: { ...body, chattype } };
725
+ const chatId = chattype === 'group' ? body.chatid : senderId;
726
+ const access = evaluateInboundAccess(this.#accessPolicy, {
727
+ conversationType: chattype === 'single' ? 'direct' : 'group', senderIds: senderId, isCommand: true,
728
+ });
729
+ if (!access.allowed) {
730
+ if (type === 'template_card_event' && access.reason === 'command-not-allowed') {
731
+ await this.#sendActive(chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE));
732
+ }
733
+ return;
734
+ }
735
+ await this.#state.markSeen(body.msgid);
736
+ const key = conversationKey(normalized);
737
+ if (type === 'enter_chat') {
738
+ if (chattype === 'single') await this.#showMain(normalized, { welcome: true });
739
+ return;
740
+ }
741
+ // Live callbacks nest these fields; older SDK examples show them flat.
742
+ const callback = body.event.template_card_event ?? body.event;
743
+ const entry = this.#menus.get(callback.task_id);
744
+ const choice = typeof callback.event_key === 'string' && /^\d+$/u.test(callback.event_key)
745
+ ? entry?.menu.entries[callback.event_key] : null;
746
+ if (!entry || entry.expiresAt <= Date.now() || !Array.isArray(choice)
747
+ || entry.key !== key) {
748
+ await this.#sendActive(chatId, t('这个菜单已过期,请回复 /m 重新打开。'));
749
+ return;
750
+ }
751
+ this.#menus.delete(callback.task_id);
752
+ // Acknowledge before Host RPC. Keep a valid button card: text_notice requires a URL.
753
+ const acknowledgedMenu = { title: choice[0], description: t('已收到操作,请查看下方结果。'),
754
+ entries: [[t('重新打开菜单'), '/menu']] };
755
+ await this.#client.updateTemplateCard(frame, wecomTemplateCard(acknowledgedMenu, callback.task_id), [senderId]).then(() => {
756
+ this.#menus.set(callback.task_id, { ...entry, menu: acknowledgedMenu });
757
+ }).catch((error) => {
758
+ this.#logger.warn?.('[dsh-im:wecom] menu acknowledgement failed:', error);
759
+ });
760
+ let commands = [choice[1]];
761
+ if (choice[1] === 'apply' || choice[1].startsWith('select:')) {
762
+ const selectors = (entry.menu.selectors ?? []).filter((item) => choice[1] === 'apply' || item.key === choice[1].slice(7));
763
+ const selectedItems = callback.selected_items?.selected_item ?? callback.selected_items ?? [];
764
+ if (!Array.isArray(selectedItems)) {
765
+ await this.#sendActive(chatId, t('请选择一个选项后再应用。'));
766
+ return;
767
+ }
768
+ commands = [];
769
+ for (const selector of selectors) {
770
+ const selected = selectedItems.find((item) => item?.question_key === selector.key);
771
+ const ids = selected?.option_ids?.option_id ?? selected?.option_ids ?? ['0'];
772
+ const id = ids[0];
773
+ if (!Array.isArray(ids) || ids.length !== 1 || !/^\d+$/u.test(id) || !selector.entries[Number(id)]) {
774
+ await this.#sendActive(chatId, t('请选择一个选项后再应用。'));
775
+ return;
776
+ }
777
+ const command = selector.entries[Number(id)][1];
778
+ if (command) commands.push(command);
779
+ }
780
+ }
781
+ const navigation = commands.find((command) => parseWecomMenu(command));
782
+ if (navigation) commands = [navigation];
783
+ if (!navigation && entry.workspace !== this.#harness.currentWorkspace?.()) {
784
+ await this.#sendActive(chatId, t('工作区已变化,请从新菜单重新选择。'));
785
+ } else {
786
+ for (const [index, command] of commands.entries()) {
787
+ const commandFrame = { ...normalized, body: { ...normalized.body,
788
+ msgid: `card:${body.msgid}:${index}`, msgtype: 'text', text: { content: command },
789
+ } };
790
+ this.#cardFrames.add(commandFrame);
791
+ await this.accept(commandFrame);
792
+ }
793
+ if (!commands.length) await this.#sendActive(chatId, t('设置未改变。'));
794
+ }
795
+ if (!navigation) {
796
+ this.#cardFrames.add(normalized);
797
+ await this.#showMain(normalized);
798
+ }
799
+ }
800
+
801
+ async #runMenuCommand(frame, text, _harness, _state, key) {
802
+ const menu = parseWecomMenu(text);
803
+ const options = { signal: this.#signal };
804
+ if (menu) {
805
+ let content;
806
+ if (menu.section === 'main') {
807
+ await this.#showMain(frame);
808
+ return { messages: [] };
809
+ }
810
+ else {
811
+ let title;
812
+ let entries;
813
+ let description = '';
814
+ if (menu.section === 'sessions') {
815
+ title = t('📋 会话列表');
816
+ const listed = await this.#harness.listWorkspaceSessions(this.#harness.currentWorkspace(), options);
817
+ entries = listed.sessions.map((session) => [
818
+ `${session.sessionId === this.#state.sessionFor(key) ? '✓ ' : ''}${session.title || t('暂无标题')}`,
819
+ `/session ${session.sessionId}`,
820
+ ]);
821
+ } else if (menu.section === 'workspaces') {
822
+ title = t('🗂 工作区列表');
823
+ const { paths, current } = await workspacePathSnapshot(this.#harness, options);
824
+ entries = paths.map((path) => [`${path === current ? '✓ ' : ''}${path}`, `/workspace ${path}`]);
825
+ } else if (menu.section === 'models') {
826
+ title = t('🧠 切换模型');
827
+ const sessionId = this.#state.sessionFor(key);
828
+ const session = sessionId ? this.#harness.workspaceSession?.(sessionId) : null;
829
+ const catalog = typeof session?.models === 'function'
830
+ ? await session.models(options) : await this.#harness.listModels(options);
831
+ entries = catalog.groups.flatMap((group) => group.models.map((model) => [
832
+ `${catalog.current?.provider === group.id && catalog.current?.model === model.id ? '✓ ' : ''}${model.name} (${group.id})`,
833
+ `/model ${group.id}/${model.id}`,
834
+ ]));
835
+ if (catalog.failures?.length) description = t('部分模型暂不可用,可稍后重试。');
836
+ } else {
837
+ title = t('🤖 切换预设');
838
+ const settings = await this.#harness.agentPresetSettings(options);
839
+ entries = [[t('🔄 跟随默认'), '/preset --default'], ...settings.agentPresetCatalog.items.map((item) => [
840
+ `${settings.agentPreset === item.id ? '✓ ' : ''}${item.label || item.id}`,
841
+ `/preset ${/^\d+$/u.test(item.id) ? 'id:' : ''}${item.id}`,
842
+ ])];
843
+ description = t('预设仅用于之后的新会话。');
844
+ }
845
+ this.#harness.assertWorkspaceScope?.();
846
+ content = wecomList({ title, entries, description, ...menu });
847
+ }
848
+ await this.#sendMenu(frame, content);
849
+ return { messages: [] };
850
+ }
851
+ const command = text.toLowerCase();
852
+ if (command === '/help') return { message: helpText() };
853
+ if (command === '/status') {
854
+ await this.#harness.ensureRunning(options);
855
+ return { message: t('企业微信机器人与 DeepSeek Harness 连接正常。') };
856
+ }
857
+ if (this.#queues.has(key) || this.#pendingInteractions.has(key) || this.#approvals.hasPending(key)
858
+ || this.#batchInputs.status(key).phase !== 'idle') {
859
+ return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。') };
860
+ }
861
+ if (command === '/new') {
862
+ await this.#state.clearSession(key);
863
+ return { message: t('已开启新会话。请发送你的问题。') };
864
+ }
865
+ return await runWorkspaceCommand(text, this.#harness, key)
866
+ ?? await runCompactCommand(text, this.#harness, this.#state, key, options);
867
+ }
868
+
596
869
  accept(frame) {
597
870
  if (this.#signal?.aborted) return Promise.resolve();
598
871
  const body = bodyOf(frame);
@@ -617,6 +890,8 @@ export class WecomHarnessBridge {
617
890
  text: commandText,
618
891
  hasImages: hasInboundImages(commandMessage),
619
892
  hasFiles: hasInboundFiles(commandMessage),
893
+ ...(parseWecomMenu(commandText) && !hasInboundImages(commandMessage) && !hasInboundFiles(commandMessage)
894
+ ? { isCommand: true } : {}),
620
895
  });
621
896
  if (!access.allowed) {
622
897
  this.#acceptedMessageIds.set(messageId, null);
@@ -663,12 +938,16 @@ export class WecomHarnessBridge {
663
938
  return this.#finishBatchResult(frame, messageId, chatId, result);
664
939
  }
665
940
  }
941
+ const menuCommand = !hasInboundImages(commandMessage) && !hasInboundFiles(commandMessage)
942
+ && (parseWecomMenu(commandText) || ['/help', '/status', '/new'].includes(commandText.toLowerCase())
943
+ || this.#cardFrames.has(frame));
666
944
  const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
667
945
  : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
668
946
  ? runControlCommand
669
947
  : (isModelCommand(commandText)
670
948
  ? runModelCommand
671
- : (isPresetCommand(commandText) ? runPresetCommand : null));
949
+ : (isPresetCommand(commandText) ? runPresetCommand : menuCommand
950
+ ? (...args) => this.#runMenuCommand(frame, ...args) : null));
672
951
  if (commandRunner) {
673
952
  let task;
674
953
  task = this.#processFastCommand(
@@ -850,6 +1129,11 @@ export class WecomHarnessBridge {
850
1129
  return task;
851
1130
  }
852
1131
 
1132
+ async #deliverDeferredOutcome(entry, outcome) {
1133
+ await this.#sendActive(entry.target.chatId, deferredOutcomeText(outcome));
1134
+ return true;
1135
+ }
1136
+
853
1137
  async waitForIdle() {
854
1138
  await Promise.allSettled([
855
1139
  ...this.#queues.values(),
@@ -859,6 +1143,7 @@ export class WecomHarnessBridge {
859
1143
  ...this.#approvalTasks,
860
1144
  ...this.#commandTasks,
861
1145
  ]);
1146
+ await this.#deferred.whenIdle();
862
1147
  }
863
1148
 
864
1149
  async #processFastCommand(frame, messageId, chatId, key, message, runner) {
@@ -875,6 +1160,7 @@ export class WecomHarnessBridge {
875
1160
  pendingInteraction: this.#pendingInteractions.has(key)
876
1161
  || this.#approvals.hasPending(key),
877
1162
  control: { owner: this, key },
1163
+ deferredDelivery: this.#deferred,
878
1164
  });
879
1165
  if (result?.stopped) {
880
1166
  await Promise.allSettled([
@@ -904,6 +1190,7 @@ export class WecomHarnessBridge {
904
1190
 
905
1191
  async #sendImmediate(frame, chatId, text) {
906
1192
  this.#signal?.throwIfAborted();
1193
+ if (this.#cardFrames.has(frame)) return this.#sendActive(chatId, text);
907
1194
  const chunks = splitUtf8(text);
908
1195
  if (chunks.length === 0) return;
909
1196
  try {
@@ -1064,6 +1351,7 @@ export class WecomHarnessBridge {
1064
1351
  await this.#state.markSeen(messageId);
1065
1352
  promptRecorded = true;
1066
1353
  const { answer, artifacts = [] } = await askInWorkspaceSession({
1354
+ deferredDelivery: () => ({ coordinator: this.#deferred, target: { chatId } }),
1067
1355
  harness: this.#harness,
1068
1356
  state: this.#state,
1069
1357
  key,
@@ -0,0 +1,101 @@
1
+ import { t } from '../shared/i18n.mjs';
2
+
3
+ const MENU_COMMAND = /^\/m(?:enu)?(?:\s+(sessions|workspaces|models|presets)(?:\s+(\d+))?)?$/iu;
4
+
5
+ export function parseWecomMenu(text) {
6
+ const match = MENU_COMMAND.exec(text.trim());
7
+ return match ? { section: match[1]?.toLowerCase() ?? 'main', page: Number(match[2] ?? 0) } : null;
8
+ }
9
+
10
+ function clean(value, length = 80) {
11
+ return [...String(value ?? '').replace(/[\p{Cc}\p{Cf}]/gu, ' ')].slice(0, length).join('');
12
+ }
13
+
14
+ function selector(key, title, entries, currentLabel, currentCommand, moreCommand) {
15
+ const choices = entries.filter(([, command]) => command !== currentCommand);
16
+ const visible = choices.slice(0, choices.length > 9 ? 8 : 9);
17
+ return {
18
+ key, title,
19
+ entries: [[currentLabel || t('保持当前'), null], ...visible,
20
+ ...(choices.length > 9 ? [[t('更多选项…'), moreCommand]] : [])],
21
+ };
22
+ }
23
+
24
+ export function wecomSettings({ sessions = [], models = [], presets = [], sessionId,
25
+ currentModel, currentPreset, presetLabel } = {}) {
26
+ return {
27
+ title: t('🤖 助手中心'),
28
+ description: t('选择会话、模型或预设后,点击应用设置。'),
29
+ selectors: [
30
+ selector('session', t('会话'), sessions,
31
+ sessions.find(([, command]) => command === `/session ${sessionId}`)?.[0] || t('新会话'),
32
+ `/session ${sessionId}`, '/menu sessions'),
33
+ selector('model', t('模型'), models,
34
+ models.find(([, command]) => command === currentModel)?.[0] || t('跟随默认模型'),
35
+ currentModel, '/menu models'),
36
+ selector('preset', t('预设'), presets, presetLabel || t('跟随 Host 默认'),
37
+ currentPreset, '/menu presets'),
38
+ ],
39
+ entries: [[t('应用设置'), 'apply']],
40
+ };
41
+ }
42
+
43
+ export function wecomMenu({ workspace, workspaces = [] } = {}) {
44
+ return {
45
+ title: t('工作区与任务'),
46
+ selectors: [selector('workspace', t('工作区'), workspaces,
47
+ workspace, `/workspace ${workspace}`, '/menu workspaces')],
48
+ entries: [
49
+ [t('切换工作区'), 'select:workspace'],
50
+ [t('🆕 新会话'), '/new'],
51
+ [t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'],
52
+ [t('📊 状态'), '/status'], [t('📖 帮助'), '/help'],
53
+ ],
54
+ };
55
+ }
56
+
57
+ // A native selector carries names; buttons only apply or navigate the selection.
58
+ export function wecomList({ title, section, entries, page = 0, description = '' }) {
59
+ const pages = Math.max(1, Math.ceil(entries.length / 10));
60
+ const current = Math.min(Math.max(0, page), pages - 1);
61
+ const visible = entries.slice(current * 10, current * 10 + 10);
62
+ const buttons = visible.length ? [[t('应用选择'), 'select:choice']] : [];
63
+ if (current > 0) buttons.push([t('上一页'), `/menu ${section} ${current - 1}`]);
64
+ if (current + 1 < pages) buttons.push([t('下一页'), `/menu ${section} ${current + 1}`]);
65
+ buttons.push([t('返回'), '/menu']);
66
+ return {
67
+ title,
68
+ description: `${current + 1} / ${pages}${description ? ` · ${description}` : ''}`,
69
+ detail: visible.length ? '' : t('暂无可用选项。'),
70
+ selectors: visible.length ? [{ key: 'choice', title, entries: visible }] : [],
71
+ entries: buttons,
72
+ };
73
+ }
74
+
75
+ export function wecomTemplateCard(menu, taskId) {
76
+ const selections = menu.selectors?.map((item) => ({
77
+ question_key: item.key, title: clean(item.title, 13), selected_id: '0',
78
+ option_list: item.entries.map(([label], index) => ({ id: String(index), text: clean(label, 60) })),
79
+ })) ?? [];
80
+ const multiple = selections.length > 1;
81
+ return {
82
+ card_type: multiple ? 'multiple_interaction' : 'button_interaction',
83
+ task_id: taskId,
84
+ main_title: { title: clean(menu.title, 36),
85
+ ...(menu.description ? { desc: clean(menu.description, 100) } : {}) },
86
+ ...(menu.detail ? { sub_title_text: menu.detail.slice(0, 500) } : {}),
87
+ ...(multiple ? { select_list: selections, submit_button: { text: menu.entries[0][0], key: '0' } }
88
+ : { ...(selections.length ? { button_selection: selections[0] } : {}),
89
+ button_list: menu.entries.map(([label], index) => ({
90
+ text: clean(label, 36), key: String(index), style: index === 0 ? 1 : 2,
91
+ })) }),
92
+ };
93
+ }
94
+
95
+ export function wecomMenuText(menu) {
96
+ return [menu.title, menu.description, menu.detail,
97
+ ...menu.selectors?.flatMap((item) => item.entries.filter(([, command]) => command)
98
+ .map(([label, command]) => `${label}:${command}`)) ?? [],
99
+ ...menu.entries.filter(([, command]) => command.startsWith('/'))
100
+ .map(([label, command]) => `${label}:${command}`)].filter(Boolean).join('\n');
101
+ }
@@ -168,6 +168,8 @@ export class WecomRuntime {
168
168
  client.on('reconnecting', onReconnecting);
169
169
  client.on('error', onError);
170
170
  client.on('message', onMessage);
171
+ client.on('event.enter_chat', (frame) => this.#bridge?.acceptEvent(frame));
172
+ client.on('event.template_card_event', (frame) => this.#bridge?.acceptEvent(frame));
171
173
 
172
174
  let timer;
173
175
  try {