@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.
- package/PROACTIVE_DELIVERY.en.md +4 -0
- package/PROACTIVE_DELIVERY.md +4 -0
- package/README.en.md +12 -2
- package/README.md +12 -2
- package/assets/dingtalk-menu-template.json +6 -0
- package/lib/client.js +1211 -1034
- package/lib/index.js +270 -264
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/index.js +16 -5
- package/plugin-src/client/channels/feishu/index.js +45 -36
- package/plugin-src/client/channels/qq/index.js +16 -5
- package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
- package/plugin-src/client/channels/shared/token-channel.js +39 -28
- package/plugin-src/client/channels/wecom/index.js +16 -5
- package/plugin-src/client/channels/weixin/index.js +16 -5
- package/plugin-src/client/channels/whatsapp/index.js +16 -5
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/styles.js +14 -1
- 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 +74 -3
- 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 +3 -0
- 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 +21 -1
- package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +6 -0
- package/src/channels/shared/i18n-en/qq.mjs +39 -0
- package/src/channels/shared/i18n-en/wecom.mjs +24 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- 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 +294 -6
- package/src/channels/wecom/wecom-cards.mjs +101 -0
- package/src/channels/wecom/wecom-runtime.mjs +2 -0
- 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
- 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
|
-
|
|
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
|
-
|
|
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 :
|
|
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 {
|