@xmanrui/dsh-im 4.18.0 → 4.19.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/README.en.md +8 -3
- package/README.md +8 -3
- package/lib/client.js +809 -515
- package/lib/index.js +269 -267
- package/package.json +5 -1
- package/plugin-src/client/bot-alias.js +92 -0
- package/plugin-src/client/channels/dingtalk/api.js +3 -0
- package/plugin-src/client/channels/dingtalk/index.js +7 -1
- package/plugin-src/client/channels/feishu/api.js +3 -0
- package/plugin-src/client/channels/feishu/index.js +7 -1
- package/plugin-src/client/channels/qq/api.js +3 -0
- package/plugin-src/client/channels/qq/index.js +9 -1
- package/plugin-src/client/channels/shared/token-api.js +3 -0
- package/plugin-src/client/channels/shared/token-channel.js +9 -2
- package/plugin-src/client/channels/wecom/api.js +3 -0
- package/plugin-src/client/channels/wecom/index.js +9 -1
- package/plugin-src/client/channels/wecom-app/api.js +3 -0
- package/plugin-src/client/channels/wecom-app/index.js +9 -1
- package/plugin-src/client/channels/weixin/api.js +3 -0
- package/plugin-src/client/channels/weixin/index.js +7 -1
- package/plugin-src/client/channels/whatsapp/api.js +3 -0
- package/plugin-src/client/channels/whatsapp/index.js +9 -1
- package/plugin-src/client/i18n.js +10 -0
- package/plugin-src/client/styles.js +26 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
- package/plugin-src/host/channels/feishu/production.mjs +22 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +14 -0
- package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
- package/plugin-src/host/channels/qq/rpc.mjs +11 -0
- package/plugin-src/host/channels/shared/bot-alias-rpc.mjs +15 -0
- package/plugin-src/host/channels/shared/rpc.mjs +9 -0
- package/plugin-src/host/channels/slack/rpc.mjs +10 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +13 -0
- package/plugin-src/host/rpc-authority.mjs +3 -2
- package/plugin-src/host/session-sync-coordinator.mjs +19 -1
- package/scripts/verify-lan-management.mjs +176 -0
- package/src/channels/feishu/bridge.mjs +370 -7
- package/src/channels/feishu/feishu-cards.mjs +1 -1
- package/src/channels/feishu/feishu-runtime.mjs +6 -0
- package/src/channels/feishu/state-store.mjs +17 -0
- package/src/channels/shared/bot-alias.mjs +29 -0
- package/src/channels/shared/bot-workspace-store.mjs +71 -3
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
- package/src/channels/shared/message-failure.mjs +2 -1
- package/src/channels/shared/model-command.mjs +3 -2
- package/src/channels/shared/session-sync-registry.mjs +34 -0
- package/src/channels/shared/text-harness-bridge.mjs +38 -0
- package/src/channels/telegram/telegram-runtime.mjs +77 -25
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateBotAlias, withBotAlias } from './bot-alias.mjs';
|
|
1
2
|
import {
|
|
2
3
|
mkdir,
|
|
3
4
|
readFile,
|
|
@@ -264,6 +265,16 @@ function normalizeDocument(value) {
|
|
|
264
265
|
if (value.version === 1 && value.deliveryTargets !== undefined) return null;
|
|
265
266
|
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets, { version: value.version });
|
|
266
267
|
if (!deliveryTargets) return null;
|
|
268
|
+
const aliases = Object.create(null);
|
|
269
|
+
if (value.aliases && typeof value.aliases === 'object' && !Array.isArray(value.aliases)) {
|
|
270
|
+
for (const [botId, alias] of Object.entries(value.aliases)) {
|
|
271
|
+
try {
|
|
272
|
+
botIdOf(botId);
|
|
273
|
+
const normalized = validateBotAlias(alias);
|
|
274
|
+
if (normalized) aliases[botId] = normalized;
|
|
275
|
+
} catch { /* A damaged display name must not disable the bot. */ }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
267
278
|
const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
|
|
268
279
|
const version = Math.max(
|
|
269
280
|
value.version,
|
|
@@ -279,6 +290,7 @@ function normalizeDocument(value) {
|
|
|
279
290
|
contextEnhancement,
|
|
280
291
|
deliveryTargets,
|
|
281
292
|
accessPolicies,
|
|
293
|
+
aliases,
|
|
282
294
|
};
|
|
283
295
|
}
|
|
284
296
|
|
|
@@ -290,8 +302,10 @@ function storedDocument({
|
|
|
290
302
|
contextEnhancement,
|
|
291
303
|
deliveryTargets,
|
|
292
304
|
accessPolicies,
|
|
305
|
+
aliases,
|
|
293
306
|
}) {
|
|
294
307
|
const document = { version, workspaces };
|
|
308
|
+
if (Object.keys(aliases).length > 0) document.aliases = aliases;
|
|
295
309
|
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
|
|
296
310
|
if (Object.keys(models).length > 0) document.models = models;
|
|
297
311
|
if (Object.keys(contextEnhancement).length > 0) {
|
|
@@ -346,6 +360,7 @@ export class BotWorkspaceStore {
|
|
|
346
360
|
#workspaces = {};
|
|
347
361
|
#agentPresets = {};
|
|
348
362
|
#models = {};
|
|
363
|
+
#aliases = Object.create(null);
|
|
349
364
|
#contextEnhancement = {};
|
|
350
365
|
#deliveryTargets = Object.create(null);
|
|
351
366
|
#accessPolicies = Object.create(null);
|
|
@@ -373,6 +388,7 @@ export class BotWorkspaceStore {
|
|
|
373
388
|
this.#workspaces = normalized.workspaces;
|
|
374
389
|
this.#agentPresets = normalized.agentPresets;
|
|
375
390
|
this.#models = normalized.models;
|
|
391
|
+
this.#aliases = normalized.aliases;
|
|
376
392
|
this.#contextEnhancement = normalized.contextEnhancement;
|
|
377
393
|
this.#deliveryTargets = normalized.deliveryTargets;
|
|
378
394
|
this.#accessPolicies = normalized.accessPolicies;
|
|
@@ -382,6 +398,7 @@ export class BotWorkspaceStore {
|
|
|
382
398
|
this.#workspaces = {};
|
|
383
399
|
this.#agentPresets = {};
|
|
384
400
|
this.#models = {};
|
|
401
|
+
this.#aliases = Object.create(null);
|
|
385
402
|
this.#contextEnhancement = {};
|
|
386
403
|
this.#deliveryTargets = Object.create(null);
|
|
387
404
|
this.#accessPolicies = Object.create(null);
|
|
@@ -427,6 +444,11 @@ export class BotWorkspaceStore {
|
|
|
427
444
|
return selection ? { ...selection } : null;
|
|
428
445
|
}
|
|
429
446
|
|
|
447
|
+
aliasFor(botId) {
|
|
448
|
+
const id = botIdOf(botId);
|
|
449
|
+
return this.has(id) && Object.hasOwn(this.#aliases, id) ? this.#aliases[id] : '';
|
|
450
|
+
}
|
|
451
|
+
|
|
430
452
|
contextEnhancementFor(botId) {
|
|
431
453
|
const id = botIdOf(botId);
|
|
432
454
|
return this.has(id) && Object.hasOwn(this.#contextEnhancement, id)
|
|
@@ -745,6 +767,26 @@ export class BotWorkspaceStore {
|
|
|
745
767
|
});
|
|
746
768
|
}
|
|
747
769
|
|
|
770
|
+
async setAlias(botId, value, { incarnation } = {}) {
|
|
771
|
+
const id = botIdOf(botId);
|
|
772
|
+
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
|
|
773
|
+
const alias = validateBotAlias(value);
|
|
774
|
+
return this.#enqueue(id, async () => {
|
|
775
|
+
if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
|
|
776
|
+
const error = new Error('找不到要修改的机器人。');
|
|
777
|
+
error.code = 'workspace-bot-not-found';
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
const next = { ...this.#aliases };
|
|
781
|
+
if (alias) next[id] = alias;
|
|
782
|
+
else delete next[id];
|
|
783
|
+
await this.#persist(this.#contextEnhancement, this.#deliveryTargets,
|
|
784
|
+
this.#version, this.#accessPolicies, next);
|
|
785
|
+
this.#aliases = next;
|
|
786
|
+
return alias;
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
748
790
|
async setContextEnhancement(botId, value, { incarnation } = {}) {
|
|
749
791
|
const id = botIdOf(botId);
|
|
750
792
|
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
|
|
@@ -945,6 +987,7 @@ export class BotWorkspaceStore {
|
|
|
945
987
|
...Object.keys(this.#workspaces),
|
|
946
988
|
...Object.keys(this.#agentPresets),
|
|
947
989
|
...Object.keys(this.#models),
|
|
990
|
+
...Object.keys(this.#aliases),
|
|
948
991
|
...Object.keys(this.#contextEnhancement),
|
|
949
992
|
...Object.keys(this.#deliveryTargets),
|
|
950
993
|
...Object.keys(this.#accessPolicies),
|
|
@@ -962,6 +1005,7 @@ export class BotWorkspaceStore {
|
|
|
962
1005
|
bots: status.bots.map((bot) => bot?.botId
|
|
963
1006
|
? {
|
|
964
1007
|
...bot,
|
|
1008
|
+
...(this.aliasFor(bot.botId) ? { bot: withBotAlias(bot.bot, this.aliasFor(bot.botId)) } : {}),
|
|
965
1009
|
workspace: this.workspaceFor(bot.botId),
|
|
966
1010
|
agentPreset: this.agentPresetFor(bot.botId),
|
|
967
1011
|
model: this.modelFor(bot.botId),
|
|
@@ -997,14 +1041,16 @@ export class BotWorkspaceStore {
|
|
|
997
1041
|
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
|
|
998
1042
|
const hadPreset = Object.hasOwn(this.#agentPresets, id);
|
|
999
1043
|
const hadModel = Object.hasOwn(this.#models, id);
|
|
1044
|
+
const hadAlias = Object.hasOwn(this.#aliases, id);
|
|
1000
1045
|
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
|
|
1001
1046
|
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
|
|
1002
1047
|
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
|
|
1003
|
-
const needsCleanup = hadWorkspace || hadPreset || hadModel || hadContextEnhancement
|
|
1048
|
+
const needsCleanup = hadWorkspace || hadPreset || hadModel || hadAlias || hadContextEnhancement
|
|
1004
1049
|
|| hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
|
|
1005
1050
|
delete this.#workspaces[id];
|
|
1006
1051
|
delete this.#agentPresets[id];
|
|
1007
1052
|
delete this.#models[id];
|
|
1053
|
+
delete this.#aliases[id];
|
|
1008
1054
|
delete this.#contextEnhancement[id];
|
|
1009
1055
|
delete this.#deliveryTargets[id];
|
|
1010
1056
|
delete this.#accessPolicies[id];
|
|
@@ -1042,6 +1088,7 @@ export class BotWorkspaceStore {
|
|
|
1042
1088
|
deliveryTargets = this.#deliveryTargets,
|
|
1043
1089
|
version = this.#version,
|
|
1044
1090
|
accessPolicies = this.#accessPolicies,
|
|
1091
|
+
aliases = this.#aliases,
|
|
1045
1092
|
) {
|
|
1046
1093
|
await writeStoredDocument(this.#path, storedDocument({
|
|
1047
1094
|
version,
|
|
@@ -1051,6 +1098,7 @@ export class BotWorkspaceStore {
|
|
|
1051
1098
|
contextEnhancement,
|
|
1052
1099
|
deliveryTargets,
|
|
1053
1100
|
accessPolicies,
|
|
1101
|
+
aliases,
|
|
1054
1102
|
}));
|
|
1055
1103
|
this.#dirtyRemovals.clear();
|
|
1056
1104
|
}
|
|
@@ -1059,6 +1107,7 @@ export class BotWorkspaceStore {
|
|
|
1059
1107
|
if (Object.keys(this.#workspaces).length > 0
|
|
1060
1108
|
|| Object.keys(this.#agentPresets).length > 0
|
|
1061
1109
|
|| Object.keys(this.#models).length > 0
|
|
1110
|
+
|| Object.keys(this.#aliases).length > 0
|
|
1062
1111
|
|| Object.keys(this.#contextEnhancement).length > 0
|
|
1063
1112
|
|| Object.keys(this.#deliveryTargets).length > 0
|
|
1064
1113
|
|| Object.keys(this.#accessPolicies).length > 0) {
|
|
@@ -1350,6 +1399,7 @@ export function createBotWorkspaceScope(
|
|
|
1350
1399
|
}
|
|
1351
1400
|
if (property === 'createSession') {
|
|
1352
1401
|
return async (options = {}) => {
|
|
1402
|
+
const { inheritBotModel = true, ...createOptions } = options;
|
|
1353
1403
|
await workspaces.whenBotIdle(botId);
|
|
1354
1404
|
if (!isCurrentScope()) {
|
|
1355
1405
|
const error = new Error('找不到要修改的机器人。');
|
|
@@ -1358,9 +1408,9 @@ export function createBotWorkspaceScope(
|
|
|
1358
1408
|
}
|
|
1359
1409
|
const generation = workspaces.generationFor(botId);
|
|
1360
1410
|
const agentPreset = workspaces.agentPresetFor(botId);
|
|
1361
|
-
const model = workspaces.modelFor(botId);
|
|
1411
|
+
const model = inheritBotModel === false ? null : workspaces.modelFor(botId);
|
|
1362
1412
|
const sessionId = await target.createSession({
|
|
1363
|
-
...
|
|
1413
|
+
...createOptions,
|
|
1364
1414
|
workspace: workspaces.workspaceFor(botId),
|
|
1365
1415
|
...(agentPreset == null ? {} : { agentPreset }),
|
|
1366
1416
|
});
|
|
@@ -1645,6 +1695,23 @@ export function createWorkspaceAwareController(controller, {
|
|
|
1645
1695
|
);
|
|
1646
1696
|
});
|
|
1647
1697
|
};
|
|
1698
|
+
const updateAlias = (botId, value, projectStatus) => {
|
|
1699
|
+
const incarnation = workspaces.incarnationFor(botId);
|
|
1700
|
+
const alias = validateBotAlias(value);
|
|
1701
|
+
return withBotTransition(botId, async () => {
|
|
1702
|
+
const snapshot = await decorate(await controller.status());
|
|
1703
|
+
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
|
|
1704
|
+
const error = new Error('找不到要修改的机器人。');
|
|
1705
|
+
error.code = 'workspace-bot-not-found';
|
|
1706
|
+
throw error;
|
|
1707
|
+
}
|
|
1708
|
+
const updated = { ...snapshot, bots: snapshot.bots.map((bot) => bot.botId === botId
|
|
1709
|
+
? { ...bot, bot: withBotAlias(bot.bot, alias) } : bot) };
|
|
1710
|
+
const result = projectStatus ? await projectStatus(updated) : updated;
|
|
1711
|
+
await workspaces.setAlias(botId, alias, { incarnation });
|
|
1712
|
+
return result;
|
|
1713
|
+
});
|
|
1714
|
+
};
|
|
1648
1715
|
const updateContextEnhancement = (botId, value, projectStatus) => {
|
|
1649
1716
|
const incarnation = workspaces.incarnationFor(botId);
|
|
1650
1717
|
const config = validateContextEnhancementConfig(value);
|
|
@@ -1743,6 +1810,7 @@ export function createWorkspaceAwareController(controller, {
|
|
|
1743
1810
|
if (property === 'updateWorkspace') return updateWorkspace;
|
|
1744
1811
|
if (property === 'updateAgentPreset') return updateAgentPreset;
|
|
1745
1812
|
if (property === 'updateModel') return updateModel;
|
|
1813
|
+
if (property === 'updateAlias') return updateAlias;
|
|
1746
1814
|
if (property === 'updateContextEnhancement') return updateContextEnhancement;
|
|
1747
1815
|
if (property === 'updateAccessPolicy') return updateAccessPolicy;
|
|
1748
1816
|
const value = Reflect.get(target, property, target);
|
|
@@ -57,8 +57,8 @@ export default {
|
|
|
57
57
|
'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
|
|
58
58
|
'当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
|
|
59
59
|
'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
|
|
60
|
-
'当前模型不存在或不支持所选配置。请发送 /models
|
|
61
|
-
'The current model does not exist or does not support the selected settings. Send /models
|
|
60
|
+
'当前模型不存在或不支持所选配置。请发送 /models,再用 /model <序号> 为当前聊天重新选择。若要修改后续新会话的默认模型,请到 DSH 设置 → IM机器人 → 对应机器人卡片修改。':
|
|
61
|
+
'The current model does not exist or does not support the selected settings. Send /models, then use /model <index> to choose a model for the current conversation. To change the default model for new conversations, open DSH Settings → IM Bots → the corresponding bot card.',
|
|
62
62
|
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
|
|
63
63
|
'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
|
|
64
64
|
'当前模型不支持所选配置。请切换模型或推理等级后重试。':
|
|
@@ -47,7 +47,7 @@ const FAILURE_MESSAGES = Object.freeze({
|
|
|
47
47
|
MODEL_CONTEXT_LIMIT:
|
|
48
48
|
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
|
|
49
49
|
MODEL_UNAVAILABLE:
|
|
50
|
-
'当前模型不存在或不支持所选配置。请发送 /models
|
|
50
|
+
'当前模型不存在或不支持所选配置。请发送 /models,再用 /model <序号> 为当前聊天重新选择。若要修改后续新会话的默认模型,请到 DSH 设置 → IM机器人 → 对应机器人卡片修改。',
|
|
51
51
|
MODEL_CONFIG:
|
|
52
52
|
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
|
|
53
53
|
MODEL_TIMEOUT:
|
|
@@ -125,6 +125,7 @@ function failureCode(error) {
|
|
|
125
125
|
}
|
|
126
126
|
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
|
|
127
127
|
if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
|
|
128
|
+
if (code === 'model-unavailable' || code === 'session/model-unavailable') return 'MODEL_UNAVAILABLE';
|
|
128
129
|
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
|
|
129
130
|
if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
|
|
130
131
|
if (code === 'turn-blocked') return 'TURN_BLOCKED';
|
|
@@ -393,7 +393,7 @@ function modelErrorMessage(error, action) {
|
|
|
393
393
|
if (code === 'session-not-found') {
|
|
394
394
|
return t('当前聊天绑定的会话已不存在,请重试。');
|
|
395
395
|
}
|
|
396
|
-
if (code === 'model-unavailable') {
|
|
396
|
+
if (code === 'model-unavailable' || code === 'session/model-unavailable') {
|
|
397
397
|
if (action === 'reasoning-select') {
|
|
398
398
|
return t('无法切换推理等级。当前模型或推理等级不可用。');
|
|
399
399
|
}
|
|
@@ -704,7 +704,8 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|
|
704
704
|
|| typeof state?.setSession !== 'function') {
|
|
705
705
|
throw new TypeError('Harness cannot create a conversation session');
|
|
706
706
|
}
|
|
707
|
-
|
|
707
|
+
// An explicit choice must remain usable even when the saved bot model expires.
|
|
708
|
+
const sessionId = await harness.createSession({ ...requestOptions, inheritBotModel: false });
|
|
708
709
|
if (typeof sessionId !== 'string' || !sessionId) {
|
|
709
710
|
throw new TypeError('Harness returned an invalid session id');
|
|
710
711
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-sync ownership registry: records which delivery targets of a
|
|
3
|
+
* session are being mirrored as process cards by their owning bridge.
|
|
4
|
+
*
|
|
5
|
+
* The plain-text session-sync coordinator and the per-bridge card mirror
|
|
6
|
+
* observe the same global session events. When a bridge mirrors a turn to a
|
|
7
|
+
* specific target, the coordinator must suppress the plain-text delivery for
|
|
8
|
+
* THAT target only — other targets (same or other channels) still receive
|
|
9
|
+
* their normal text. Both sides live in the same Host process, so a
|
|
10
|
+
* module-level registry is the whole contract: the mirror claims a target
|
|
11
|
+
* when it opens the mirror card and releases it when the turn ends.
|
|
12
|
+
*/
|
|
13
|
+
const claimed = new Map();
|
|
14
|
+
|
|
15
|
+
function keyOf(sessionId, targetId) {
|
|
16
|
+
return `${sessionId}\0${targetId ?? ''}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function claimSessionSyncMirror(sessionId, targetId = '', turn = null) {
|
|
20
|
+
if (typeof sessionId !== 'string' || !sessionId) return;
|
|
21
|
+
claimed.set(keyOf(sessionId, targetId), { turn, claimedAt: Date.now() });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function releaseSessionSyncMirror(sessionId, targetId = '') {
|
|
25
|
+
claimed.delete(keyOf(sessionId, targetId));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when a live mirror claim covers this session and target. */
|
|
29
|
+
export function isSessionSyncMirrored(sessionId, targetId = '', turn = null) {
|
|
30
|
+
const claim = claimed.get(keyOf(sessionId, targetId));
|
|
31
|
+
if (!claim) return false;
|
|
32
|
+
if (turn !== null && claim.turn !== null && claim.turn !== turn) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
@@ -144,6 +144,7 @@ export class TextHarnessBridge {
|
|
|
144
144
|
#logger;
|
|
145
145
|
#replyTimeoutMs;
|
|
146
146
|
#signal;
|
|
147
|
+
#keepaliveIntervalMs;
|
|
147
148
|
#queues = new Map();
|
|
148
149
|
#pendingInteractions = new Map();
|
|
149
150
|
#interactionKeys = new Map();
|
|
@@ -165,6 +166,7 @@ export class TextHarnessBridge {
|
|
|
165
166
|
logger = console,
|
|
166
167
|
replyTimeoutMs = 600_000,
|
|
167
168
|
signal,
|
|
169
|
+
keepaliveIntervalMs = 4_000,
|
|
168
170
|
}) {
|
|
169
171
|
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
|
|
170
172
|
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
|
|
@@ -179,6 +181,7 @@ export class TextHarnessBridge {
|
|
|
179
181
|
this.#logger = logger;
|
|
180
182
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
181
183
|
this.#signal = signal;
|
|
184
|
+
this.#keepaliveIntervalMs = keepaliveIntervalMs;
|
|
182
185
|
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
183
186
|
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
184
187
|
});
|
|
@@ -586,6 +589,15 @@ export class TextHarnessBridge {
|
|
|
586
589
|
const batchSubmission = message.batchSubmission;
|
|
587
590
|
let stream = null;
|
|
588
591
|
let semanticStream = false;
|
|
592
|
+
// A keepalive heartbeat keeps short-lived carriers (e.g. Telegram's
|
|
593
|
+
// private-chat Rich Draft) visible during long silent stretches such as a
|
|
594
|
+
// running tool call. Declared outside the try so every exit path (including
|
|
595
|
+
// pre-prompt failures like image parsing) clears the timer.
|
|
596
|
+
let keepaliveTimer = null;
|
|
597
|
+
const stopKeepalive = () => {
|
|
598
|
+
if (keepaliveTimer !== null) clearInterval(keepaliveTimer);
|
|
599
|
+
keepaliveTimer = null;
|
|
600
|
+
};
|
|
589
601
|
try {
|
|
590
602
|
this.#signal?.throwIfAborted();
|
|
591
603
|
if (message.kind === 'group' && message.addressed !== true) {
|
|
@@ -685,6 +697,29 @@ export class TextHarnessBridge {
|
|
|
685
697
|
}));
|
|
686
698
|
contextEnhanced = content !== originalContent;
|
|
687
699
|
}
|
|
700
|
+
// Start the keepalive only after the inbound payload is ready, so a
|
|
701
|
+
// pre-prompt failure (image parsing, context building) cannot leave the
|
|
702
|
+
// timer running; the outermost finally below clears it on every path.
|
|
703
|
+
if (stream && stream.keepalive === true && typeof stream.refresh === 'function') {
|
|
704
|
+
let refreshing = false;
|
|
705
|
+
keepaliveTimer = setInterval(async () => {
|
|
706
|
+
// Skip ticks while the previous heartbeat is pending so redundant
|
|
707
|
+
// refreshes cannot queue ahead of the final answer on a slow network.
|
|
708
|
+
if (refreshing) return;
|
|
709
|
+
refreshing = true;
|
|
710
|
+
try {
|
|
711
|
+
await Promise.allSettled([
|
|
712
|
+
this.#bot.sendTyping?.(target),
|
|
713
|
+
stream.refresh(),
|
|
714
|
+
]);
|
|
715
|
+
} catch {
|
|
716
|
+
// Keepalive is best-effort, including synchronous adapter failures.
|
|
717
|
+
} finally {
|
|
718
|
+
refreshing = false;
|
|
719
|
+
}
|
|
720
|
+
}, this.#keepaliveIntervalMs);
|
|
721
|
+
keepaliveTimer.unref?.();
|
|
722
|
+
}
|
|
688
723
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
689
724
|
deferredDelivery: () => ({ coordinator: this.#deferred, target: this.#descriptor.key === 'whatsapp' ? { jid: target.jid, selfChat: target.selfChat } : target }),
|
|
690
725
|
harness: this.#harness,
|
|
@@ -719,6 +754,7 @@ export class TextHarnessBridge {
|
|
|
719
754
|
files: message.files,
|
|
720
755
|
},
|
|
721
756
|
});
|
|
757
|
+
stopKeepalive();
|
|
722
758
|
if (batchSubmission) {
|
|
723
759
|
this.#batches.complete(conversationKey, batchSubmission.token);
|
|
724
760
|
}
|
|
@@ -805,6 +841,7 @@ export class TextHarnessBridge {
|
|
|
805
841
|
}
|
|
806
842
|
return delivery.receipt;
|
|
807
843
|
} catch (error) {
|
|
844
|
+
stopKeepalive();
|
|
808
845
|
const turnStopped = error?.code === 'turn-stopped';
|
|
809
846
|
if (batchSubmission && turnStopped) {
|
|
810
847
|
this.#batches.complete(conversationKey, batchSubmission.token);
|
|
@@ -875,6 +912,7 @@ export class TextHarnessBridge {
|
|
|
875
912
|
}
|
|
876
913
|
return error.deliveryReceipt;
|
|
877
914
|
} finally {
|
|
915
|
+
stopKeepalive();
|
|
878
916
|
await Promise.allSettled([
|
|
879
917
|
this.#cancelPendingInteraction(conversationKey),
|
|
880
918
|
this.#approvals.closeRoute(conversationKey),
|
|
@@ -325,48 +325,99 @@ class TelegramDeliveryStream {
|
|
|
325
325
|
#providerMessageIds;
|
|
326
326
|
#closed = false;
|
|
327
327
|
#lastUpdate = null;
|
|
328
|
+
#lastBlock = null;
|
|
329
|
+
#keepalive = false;
|
|
330
|
+
#chain = Promise.resolve();
|
|
328
331
|
|
|
329
|
-
constructor({
|
|
332
|
+
constructor({
|
|
333
|
+
update,
|
|
334
|
+
finish,
|
|
335
|
+
fail,
|
|
336
|
+
providerMessageIds = [],
|
|
337
|
+
presentation,
|
|
338
|
+
logger,
|
|
339
|
+
keepalive = false,
|
|
340
|
+
}) {
|
|
330
341
|
this.#update = update;
|
|
331
342
|
this.#finish = finish;
|
|
332
343
|
this.#fail = fail;
|
|
333
344
|
this.#providerMessageIds = providerMessageIds;
|
|
334
345
|
this.presentation = presentation;
|
|
335
346
|
this.#logger = logger;
|
|
347
|
+
// Only short-lived carriers (e.g. the private-chat Rich Draft) need a
|
|
348
|
+
// keepalive refresh; editing a real placeholder message with identical
|
|
349
|
+
// content would be rejected by the platform.
|
|
350
|
+
this.#keepalive = keepalive === true;
|
|
336
351
|
}
|
|
337
352
|
|
|
338
353
|
get providerMessageIds() {
|
|
339
354
|
return [...this.#providerMessageIds];
|
|
340
355
|
}
|
|
341
356
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const key = `${block.format}:${block.text}`;
|
|
346
|
-
if (key === this.#lastUpdate) return undefined;
|
|
347
|
-
this.#lastUpdate = key;
|
|
348
|
-
try {
|
|
349
|
-
return await this.#update(block);
|
|
350
|
-
} catch (error) {
|
|
351
|
-
this.#logger.warn?.('[dsh-im:telegram] rich stream update failed:', error);
|
|
352
|
-
return undefined;
|
|
353
|
-
}
|
|
357
|
+
/** Whether this carrier is short-lived and wants a keepalive heartbeat. */
|
|
358
|
+
get keepalive() {
|
|
359
|
+
return this.#keepalive;
|
|
354
360
|
}
|
|
355
361
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
this.#
|
|
361
|
-
return
|
|
362
|
+
/** Serialize every write so an in-flight keepalive refresh can never land
|
|
363
|
+
* after the final frame: finish()/fail() queue behind refresh()/update(). */
|
|
364
|
+
#enqueue(task) {
|
|
365
|
+
const run = this.#chain.then(task);
|
|
366
|
+
this.#chain = run.catch(() => undefined);
|
|
367
|
+
return run;
|
|
362
368
|
}
|
|
363
369
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
+
update(value) {
|
|
371
|
+
return this.#enqueue(async () => {
|
|
372
|
+
if (this.#closed) return undefined;
|
|
373
|
+
const block = createTextDeliveryBlock(value);
|
|
374
|
+
this.#lastBlock = block;
|
|
375
|
+
const key = `${block.format}:${block.text}`;
|
|
376
|
+
if (key === this.#lastUpdate) return undefined;
|
|
377
|
+
this.#lastUpdate = key;
|
|
378
|
+
try {
|
|
379
|
+
return await this.#update(block);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
this.#logger.warn?.('[dsh-im:telegram] rich stream update failed:', error);
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Re-send the most recent frame even when unchanged, to keep a short-lived
|
|
388
|
+
* carrier (the private-chat Rich Draft) visible during long silent
|
|
389
|
+
* stretches such as a running tool call. Serialized like update() so it
|
|
390
|
+
* never overtakes a later finish(). No-op for carriers without keepalive. */
|
|
391
|
+
refresh() {
|
|
392
|
+
return this.#enqueue(async () => {
|
|
393
|
+
if (this.#closed || !this.#keepalive || !this.#lastBlock) return undefined;
|
|
394
|
+
try {
|
|
395
|
+
return await this.#update(this.#lastBlock);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
this.#logger.warn?.('[dsh-im:telegram] rich stream refresh failed:', error);
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
finish(value) {
|
|
404
|
+
return this.#enqueue(async () => {
|
|
405
|
+
if (this.#closed) throw new Error('Message stream is already closed');
|
|
406
|
+
this.#closed = true;
|
|
407
|
+
const result = await this.#finish(createTextDeliveryBlock(value));
|
|
408
|
+
this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
|
|
409
|
+
return result;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
fail(text) {
|
|
414
|
+
return this.#enqueue(async () => {
|
|
415
|
+
if (this.#closed) return undefined;
|
|
416
|
+
this.#closed = true;
|
|
417
|
+
const result = await this.#fail(createTextDeliveryBlock(text, 'plain'));
|
|
418
|
+
this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
|
|
419
|
+
return result;
|
|
420
|
+
});
|
|
370
421
|
}
|
|
371
422
|
|
|
372
423
|
cancel() {
|
|
@@ -663,6 +714,7 @@ export class TelegramBotClient {
|
|
|
663
714
|
finish: (block) => this.#sendRich(target, block),
|
|
664
715
|
fail: (block) => this.#sendPlain(target, block.text),
|
|
665
716
|
presentation: 'telegram-rich-draft',
|
|
717
|
+
keepalive: true,
|
|
666
718
|
logger: this.#logger,
|
|
667
719
|
});
|
|
668
720
|
await stream.update(createTextDeliveryBlock('正在处理…', 'plain'));
|