@xmanrui/dsh-im 4.29.1 → 4.31.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 +7 -4
- package/README.md +7 -4
- package/lib/client.js +578 -141
- package/lib/index.js +298 -298
- package/package.json +6 -2
- package/plugin-src/client/channels/feishu/api.js +5 -2
- package/plugin-src/client/channels/feishu/group-settings.js +14 -14
- package/plugin-src/client/channels/feishu/index.js +2 -1
- package/plugin-src/client/channels/feishu/slash-panel-settings.js +322 -0
- package/plugin-src/client/context-enhancement.js +7 -0
- package/plugin-src/client/delivery-settings.js +16 -0
- package/plugin-src/client/i18n.js +39 -6
- package/plugin-src/client/styles.js +9 -0
- package/plugin-src/host/channels/feishu/production.mjs +2 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +28 -8
- package/scripts/verify-package.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +183 -44
- package/src/channels/feishu/feishu-cards.mjs +7 -1
- package/src/channels/feishu/feishu-channel.mjs +9 -4
- package/src/channels/feishu/feishu-runtime.mjs +86 -15
- package/src/channels/feishu/message-utils.mjs +48 -12
- package/src/channels/feishu/multi-bot-controller.mjs +29 -7
- package/src/channels/feishu/plugin-config-store.mjs +3 -1
- package/src/channels/feishu/slash-command-panel.mjs +155 -0
- package/src/channels/feishu/slash-command-registry.mjs +257 -41
- package/src/channels/shared/context-enhancement.mjs +148 -7
- package/src/channels/shared/i18n-en/feishu.mjs +5 -0
- package/src/channels/weixin/weixin-bridge.mjs +28 -10
|
@@ -16,6 +16,17 @@
|
|
|
16
16
|
* existing "/xxx" text commands.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_SLASH_COMMAND_ICON,
|
|
21
|
+
SLASH_COMMAND_MANIFEST,
|
|
22
|
+
isCustomSlashPanel,
|
|
23
|
+
resolveSlashPanelManifest,
|
|
24
|
+
} from './slash-command-panel.mjs';
|
|
25
|
+
|
|
26
|
+
// The manifest describes the panel, so it lives with the panel's configuration;
|
|
27
|
+
// re-exported here because this is where callers have always imported it from.
|
|
28
|
+
export { SLASH_COMMAND_MANIFEST };
|
|
29
|
+
|
|
19
30
|
const SLASH_ENDPOINT = '/open-apis/application/v7/app_slash_commands';
|
|
20
31
|
const MISSING_PERMISSION_CODES = new Set(['99991640', '99991672']);
|
|
21
32
|
|
|
@@ -24,50 +35,15 @@ export const SLASH_COMMAND_TENANT_SCOPES = Object.freeze([
|
|
|
24
35
|
'application:app_slash_command:write',
|
|
25
36
|
]);
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
const DEFAULT_ICON = 'ai-agent_outlined';
|
|
38
|
+
const DEFAULT_ICON = DEFAULT_SLASH_COMMAND_ICON;
|
|
29
39
|
|
|
30
40
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* Descriptions should stay short and match what the command actually does in
|
|
36
|
-
* bridge.mjs / the shared command modules.
|
|
41
|
+
* Feishu returns the panel in creation order (newest first, time to the
|
|
42
|
+
* second), so a chosen order is only reachable by creating the commands in
|
|
43
|
+
* reverse, one per second. Anything faster lands in the same second and the
|
|
44
|
+
* order degrades to the platform's tie-break.
|
|
37
45
|
*/
|
|
38
|
-
export const
|
|
39
|
-
{ command: 'menu', icon: 'skill_outlined', default: '打开功能菜单', en_us: 'Open the feature menu' },
|
|
40
|
-
{ command: 'new', icon: 'ai-deepthink_outlined', default: '开启全新会话', en_us: 'Start a fresh session' },
|
|
41
|
-
{ command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
|
|
42
|
-
{ command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
|
|
43
|
-
{ command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
|
|
44
|
-
{ command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
|
|
45
|
-
{ command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
|
46
|
-
{ command: 'workspaces', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
|
47
|
-
{ command: 'wsl', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
|
48
|
-
{ command: 'ws', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
|
|
49
|
-
{ command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
|
|
50
|
-
{ command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
|
|
51
|
-
{ command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
|
|
52
|
-
{ command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
|
|
53
|
-
{ command: 'history', icon: 'chat-ai_outlined', default: '查看最近历史消息(仅私聊)', en_us: 'Show recent history (private chats only)' },
|
|
54
|
-
{ command: 'workspace', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
|
|
55
|
-
{ command: 'conv', icon: 'folder_outlined', default: '设置当前对话专属工作区', en_us: 'Set the workspace for this conversation' },
|
|
56
|
-
{ command: 'session', icon: 'chat-ai_outlined', default: '绑定已有会话', en_us: 'Bind an existing session' },
|
|
57
|
-
{ command: 'models', icon: 'ai-functions_outlined', default: '列出可用模型', en_us: 'List available models' },
|
|
58
|
-
{ command: 'model', icon: 'ai-agent_outlined', default: '查看或切换当前模型', en_us: 'Show or switch the current model' },
|
|
59
|
-
{ command: 'reasoninglist', icon: 'ai-deepthink_outlined', default: '列出可用推理等级', en_us: 'List available reasoning efforts' },
|
|
60
|
-
{ command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
|
|
61
|
-
{ command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
|
|
62
|
-
{ command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
|
|
63
|
-
{ command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
|
|
64
|
-
{ command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
|
|
65
|
-
{ command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
|
|
66
|
-
{ command: 'send', icon: 'chat-ai_outlined', default: '提交当前批次(仅私聊)', en_us: 'Submit the current batch (private chats only)' },
|
|
67
|
-
{ command: 'cancel', icon: 'clear_outlined', default: '取消当前批次(仅私聊)', en_us: 'Cancel the current batch (private chats only)' },
|
|
68
|
-
{ command: 'version', icon: 'ai-functions_outlined', default: '查看插件版本', en_us: 'Show the plugin version' },
|
|
69
|
-
{ command: 'repair', icon: 'ai-functions_outlined', default: '补全飞书权限与卡片回调(仅私聊)', en_us: 'Complete Feishu permissions and card callbacks (private chats only)' },
|
|
70
|
-
]);
|
|
46
|
+
export const SLASH_PANEL_CREATE_INTERVAL_MS = 1_100;
|
|
71
47
|
|
|
72
48
|
// Commands that require a parameter are registered too, so the user can type
|
|
73
49
|
// "/watch <session ID>" from the panel. A leading placeholder hint is not part of
|
|
@@ -276,4 +252,244 @@ export async function registerSlashCommands({
|
|
|
276
252
|
};
|
|
277
253
|
}
|
|
278
254
|
|
|
255
|
+
/** Delete one registered command with an already-fetched tenant token. */
|
|
256
|
+
async function deleteSlashCommandWithToken({
|
|
257
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal, commandId,
|
|
258
|
+
}) {
|
|
259
|
+
await requestJson(httpInstance, {
|
|
260
|
+
method: 'DELETE',
|
|
261
|
+
url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
|
|
262
|
+
headers: { authorization: `Bearer ${tenantAccessToken}` },
|
|
263
|
+
signal: requestSignal(signal, timeoutMs),
|
|
264
|
+
timeout: timeoutMs,
|
|
265
|
+
}, 'Feishu slash command delete');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Plain timer used between creates; tests inject their own. */
|
|
269
|
+
function defaultWait(ms, signal) {
|
|
270
|
+
return new Promise((resolve) => {
|
|
271
|
+
const timer = setTimeout(resolve, ms);
|
|
272
|
+
signal?.addEventListener('abort', () => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
resolve();
|
|
275
|
+
}, { once: true });
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function commandNameOf(item) {
|
|
280
|
+
return typeof item?.command === 'string' && item.command ? item.command : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The commands of ours in panel order. Feishu returns the list newest-first
|
|
285
|
+
* with second precision, so `create_time` is the panel order and `command_id`
|
|
286
|
+
* only breaks ties inside the same second.
|
|
287
|
+
*/
|
|
288
|
+
function orderedOwnedNames(items, owned) {
|
|
289
|
+
return items
|
|
290
|
+
.map((item, index) => ({ item, index }))
|
|
291
|
+
.filter(({ item }) => owned.has(commandNameOf(item)))
|
|
292
|
+
.sort((a, b) => {
|
|
293
|
+
const aTime = Number(a.item.create_time);
|
|
294
|
+
const bTime = Number(b.item.create_time);
|
|
295
|
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
|
|
296
|
+
const aId = Number(a.item.command_id);
|
|
297
|
+
const bId = Number(b.item.command_id);
|
|
298
|
+
if (Number.isFinite(aId) && Number.isFinite(bId) && aId !== bId) return bId - aId;
|
|
299
|
+
return a.index - b.index;
|
|
300
|
+
})
|
|
301
|
+
.map(({ item }) => commandNameOf(item))
|
|
302
|
+
.filter(Boolean);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function slashPanelDescription(entry, command) {
|
|
306
|
+
return {
|
|
307
|
+
default_value: entry.default ?? entry.en_us ?? command,
|
|
308
|
+
i18n: {
|
|
309
|
+
zh_cn: entry.default ?? command,
|
|
310
|
+
en_us: entry.en_us ?? entry.default ?? command,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Sync one bot's "/" panel with its configured manifest.
|
|
317
|
+
*
|
|
318
|
+
* `default` mode is the pre-configurable behaviour: create what is missing and
|
|
319
|
+
* leave everything else alone. `custom` mode converges the app to the
|
|
320
|
+
* configured commands *and order*:
|
|
321
|
+
*
|
|
322
|
+
* 1. read the registered commands (with command_id and create_time);
|
|
323
|
+
* 2. stop when the panel already holds exactly that order — Feishu reports the
|
|
324
|
+
* panel in creation order, so a matching order needs no calls at all;
|
|
325
|
+
* 3. otherwise delete our commands and recreate the configured ones
|
|
326
|
+
* newest-first, one per `intervalMs`, because the order *is* the creation
|
|
327
|
+
* order.
|
|
328
|
+
*
|
|
329
|
+
* Commands registered outside the manifest are never deleted: dsh-im does not
|
|
330
|
+
* own them, and their position is the platform's business.
|
|
331
|
+
*
|
|
332
|
+
* @returns {{ created: Array<{command,command_id}>, deleted: string[],
|
|
333
|
+
* existing: string[], external: string[], failed: Array<{command,error}>,
|
|
334
|
+
* changed: boolean, superseded: boolean }} `superseded` marks a run that
|
|
335
|
+
* stopped early because a newer panel config took over; it reports what it
|
|
336
|
+
* managed to do, and the newer run converges the panel from there.
|
|
337
|
+
*/
|
|
338
|
+
export async function syncSlashCommands({
|
|
339
|
+
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
|
|
340
|
+
signal, manifest = SLASH_COMMAND_MANIFEST, config = null,
|
|
341
|
+
intervalMs = SLASH_PANEL_CREATE_INTERVAL_MS, wait = defaultWait,
|
|
342
|
+
}) {
|
|
343
|
+
// A superseded run — the runtime aborts this signal when a newer panel config
|
|
344
|
+
// arrives — must not send further create/delete calls: the newer run re-reads
|
|
345
|
+
// the panel and converges it, and letting both write would leave the older
|
|
346
|
+
// plan's commands behind. Requests already in flight are left to finish.
|
|
347
|
+
const stopped = () => signal?.aborted === true;
|
|
348
|
+
const tenantAccessToken = await fetchTenantAccessToken({
|
|
349
|
+
appId, appSecret, domain, httpInstance, timeoutMs, signal,
|
|
350
|
+
});
|
|
351
|
+
const items = await listSlashCommandsWithToken({
|
|
352
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
353
|
+
});
|
|
354
|
+
const owned = new Set(manifest.map((entry) => commandNameOf(entry)).filter(Boolean));
|
|
355
|
+
const external = items
|
|
356
|
+
.map((item) => commandNameOf(item))
|
|
357
|
+
.filter((name) => name && !owned.has(name));
|
|
358
|
+
const registered = new Set(
|
|
359
|
+
items.map((item) => commandNameOf(item)).filter(Boolean),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (!isCustomSlashPanel(config)) {
|
|
363
|
+
const created = [];
|
|
364
|
+
const failed = [];
|
|
365
|
+
for (const entry of resolveSlashPanelManifest(config, manifest)) {
|
|
366
|
+
if (stopped()) break;
|
|
367
|
+
const command = commandNameOf(entry);
|
|
368
|
+
if (!command || registered.has(command)) continue;
|
|
369
|
+
try {
|
|
370
|
+
const commandId = await createSlashCommandWithToken({
|
|
371
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
372
|
+
command,
|
|
373
|
+
description: slashPanelDescription(entry, command),
|
|
374
|
+
icon: entry.icon ?? DEFAULT_ICON,
|
|
375
|
+
});
|
|
376
|
+
created.push({ command, command_id: commandId });
|
|
377
|
+
registered.add(command);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (stopped()) break;
|
|
380
|
+
if (error?.code === '40000000' && /already exists/i.test(error?.msg ?? '')) {
|
|
381
|
+
registered.add(command);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
failed.push({ command, error: error?.message ?? String(error) });
|
|
385
|
+
if (MISSING_PERMISSION_CODES.has(error?.code)
|
|
386
|
+
|| /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
created,
|
|
393
|
+
deleted: [],
|
|
394
|
+
existing: [...registered].filter(Boolean),
|
|
395
|
+
external,
|
|
396
|
+
failed,
|
|
397
|
+
changed: created.length > 0,
|
|
398
|
+
superseded: stopped(),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const current = orderedOwnedNames(items, owned);
|
|
403
|
+
const plannedEntries = resolveSlashPanelManifest(config, manifest)
|
|
404
|
+
.filter((entry) => commandNameOf(entry));
|
|
405
|
+
const plannedNames = plannedEntries.map((entry) => commandNameOf(entry));
|
|
406
|
+
if (current.length === plannedNames.length
|
|
407
|
+
&& plannedNames.every((name, index) => name === current[index])) {
|
|
408
|
+
return {
|
|
409
|
+
created: [], deleted: [], existing: current, external, failed: [], changed: false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A newer config took over while we were reading the panel: leave the rebuild
|
|
414
|
+
// to its run rather than deleting commands for a plan nobody wants anymore.
|
|
415
|
+
if (stopped()) {
|
|
416
|
+
return {
|
|
417
|
+
created: [], deleted: [], existing: current, external, failed: [], changed: false, superseded: true,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const byName = new Map(
|
|
422
|
+
items.map((item) => [commandNameOf(item), item]).filter(([name]) => name && owned.has(name)),
|
|
423
|
+
);
|
|
424
|
+
const deleted = [];
|
|
425
|
+
const failed = [];
|
|
426
|
+
for (const name of current) {
|
|
427
|
+
if (stopped()) break;
|
|
428
|
+
const item = byName.get(name);
|
|
429
|
+
if (!item?.command_id) {
|
|
430
|
+
failed.push({ command: name, error: 'the registered command reports no command_id' });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
await deleteSlashCommandWithToken({
|
|
435
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal, commandId: item.command_id,
|
|
436
|
+
});
|
|
437
|
+
deleted.push(name);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (stopped()) break;
|
|
440
|
+
failed.push({ command: name, error: error?.message ?? String(error) });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// A superseded run stops before rebuilding: the newer run owns the panel now.
|
|
444
|
+
if (stopped()) {
|
|
445
|
+
return {
|
|
446
|
+
created: [], deleted, existing: current, external, failed, changed: deleted.length > 0, superseded: true,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
// Deleting only part of the panel would leave it half-converged, and
|
|
450
|
+
// recreating a name that still exists is rejected as a duplicate. Stop here;
|
|
451
|
+
// the next sync (config change, reconnect, or a later start) converges.
|
|
452
|
+
if (failed.length > 0) {
|
|
453
|
+
return { created: [], deleted, existing: current, external, failed, changed: deleted.length > 0 };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const created = [];
|
|
457
|
+
for (const [index, entry] of [...plannedEntries].reverse().entries()) {
|
|
458
|
+
if (stopped()) break;
|
|
459
|
+
const command = commandNameOf(entry);
|
|
460
|
+
try {
|
|
461
|
+
if (index > 0) {
|
|
462
|
+
await wait(intervalMs, signal);
|
|
463
|
+
// The wait wakes on abort as well, so re-check instead of recording a
|
|
464
|
+
// superseded run as a failed create.
|
|
465
|
+
if (stopped()) break;
|
|
466
|
+
}
|
|
467
|
+
const commandId = await createSlashCommandWithToken({
|
|
468
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
469
|
+
command,
|
|
470
|
+
description: slashPanelDescription(entry, command),
|
|
471
|
+
icon: entry.icon ?? DEFAULT_ICON,
|
|
472
|
+
});
|
|
473
|
+
created.push({ command, command_id: commandId });
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (stopped()) break;
|
|
476
|
+
failed.push({ command, error: error?.message ?? String(error) });
|
|
477
|
+
if (MISSING_PERMISSION_CODES.has(error?.code)
|
|
478
|
+
|| /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
created,
|
|
486
|
+
deleted,
|
|
487
|
+
existing: [],
|
|
488
|
+
external,
|
|
489
|
+
failed,
|
|
490
|
+
changed: created.length > 0 || deleted.length > 0,
|
|
491
|
+
superseded: stopped(),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
279
495
|
export default registerSlashCommands;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Shared by the Host and settings UI; keep this module browser-compatible.
|
|
2
2
|
export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
|
|
3
3
|
'channel', 'conversationType', 'senderId', 'senderName', 'conversationTitle',
|
|
4
|
-
'chatId', 'threadId', 'botId',
|
|
4
|
+
'chatId', 'threadId', 'botId', 'sentAt',
|
|
5
5
|
]);
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -56,7 +56,7 @@ const CHANNELS = new Set([
|
|
|
56
56
|
]);
|
|
57
57
|
const SOURCE_LIMITS = {
|
|
58
58
|
channel: 16, conversationType: 6, senderId: 256, senderName: 256,
|
|
59
|
-
conversationTitle: 256, chatId: 256, threadId: 256, botId: 128,
|
|
59
|
+
conversationTitle: 256, chatId: 256, threadId: 256, botId: 128, sentAt: 32,
|
|
60
60
|
};
|
|
61
61
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
|
|
62
62
|
|
|
@@ -82,7 +82,7 @@ function validateContextEnhancementScope(input) {
|
|
|
82
82
|
throw invalidConfig('群聊和私聊开关必须是布尔值。');
|
|
83
83
|
}
|
|
84
84
|
if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
|
|
85
|
-
throw invalidConfig('
|
|
85
|
+
throw invalidConfig('来源字段只能选择已定义的字段。');
|
|
86
86
|
}
|
|
87
87
|
if (typeof guidance !== 'string' || guidance.length > CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH) {
|
|
88
88
|
throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
|
|
@@ -155,6 +155,70 @@ export function captureContextEnhancement(provider, conversationType) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Property a channel hangs its captured moment on, using `withSentAt`.
|
|
160
|
+
*
|
|
161
|
+
* It sits on the source factory instead of being read out of it, because a
|
|
162
|
+
* factory reads the inbound event's fields -- sometimes through throwing
|
|
163
|
+
* getters -- and only a prompt that actually renders a source block may do
|
|
164
|
+
* that. Local commands (`/help`, `/new`, `/steer`) take the enhancement to keep
|
|
165
|
+
* its privacy rules but never read a source, and that must stay true.
|
|
166
|
+
*/
|
|
167
|
+
const SENT_AT = 'sentAt';
|
|
168
|
+
|
|
169
|
+
/** One-shot latch: a wiring bug is worth saying once, not once per message. */
|
|
170
|
+
let warnedMissingSentAt = false;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Wrap one channel source factory so it also publishes the moment of the
|
|
174
|
+
* message, without reading any of the message's fields yet.
|
|
175
|
+
*
|
|
176
|
+
* `value` may itself be a thunk: a channel that derives the moment from the
|
|
177
|
+
* inbound event (a `create_time_ms` field, a message-id decode) should pass one,
|
|
178
|
+
* because evaluating it here would read the inbound message even when the scope
|
|
179
|
+
* is disabled or the `sentAt` field is not selected -- and a disabled scope must
|
|
180
|
+
* read nothing at all.
|
|
181
|
+
*
|
|
182
|
+
* @param factory - the channel's source factory, or nothing.
|
|
183
|
+
* @param value - platform send time (epoch ms or ISO-8601), or a thunk
|
|
184
|
+
* returning one. It is evaluated only when a rendered block selects `sentAt`;
|
|
185
|
+
* an unusable value, or a thunk that throws, omits the field.
|
|
186
|
+
* @returns the same shape, plus `sentAt` when a usable value was supplied.
|
|
187
|
+
*/
|
|
188
|
+
export function withSentAt(factory, value) {
|
|
189
|
+
const wrapped = typeof factory === 'function' ? (...args) => factory(...args) : () => factory;
|
|
190
|
+
const published = typeof value === 'function' ? value : () => value;
|
|
191
|
+
Object.defineProperty(wrapped, SENT_AT, { value: published, enumerable: false });
|
|
192
|
+
return wrapped;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Evaluate the moment a channel published, without invoking its source factory.
|
|
197
|
+
*
|
|
198
|
+
* Called only while a prompt is rendering a block that selected `sentAt` --
|
|
199
|
+
* never at capture time -- so an unselected field, a disabled scope, a missing
|
|
200
|
+
* provider or a local command never reads the inbound message's clock. A
|
|
201
|
+
* throwing accessor (for example a getter on the inbound event) is contained
|
|
202
|
+
* here: it costs this one field, not the message.
|
|
203
|
+
*
|
|
204
|
+
* @param source - a source factory carrying `sentAt`, or a plain source object.
|
|
205
|
+
* @returns `{ published, ms }`: whether a moment was published at all, and its
|
|
206
|
+
* epoch milliseconds when it was usable.
|
|
207
|
+
*/
|
|
208
|
+
function readPublishedSentAt(source) {
|
|
209
|
+
const own = typeof source === 'function' || (source !== null && typeof source === 'object')
|
|
210
|
+
? source?.[SENT_AT]
|
|
211
|
+
: undefined;
|
|
212
|
+
if (typeof own !== 'function') return { published: false, ms: undefined };
|
|
213
|
+
let value;
|
|
214
|
+
try {
|
|
215
|
+
value = own();
|
|
216
|
+
} catch {
|
|
217
|
+
return { published: true, ms: undefined };
|
|
218
|
+
}
|
|
219
|
+
return { published: true, ms: timestampMs(value) ?? undefined };
|
|
220
|
+
}
|
|
221
|
+
|
|
158
222
|
/**
|
|
159
223
|
* Capture the enhancement one prompt replays, together with the source factory
|
|
160
224
|
* that fills its selected fields.
|
|
@@ -167,12 +231,55 @@ export function captureContextEnhancement(provider, conversationType) {
|
|
|
167
231
|
*
|
|
168
232
|
* @param provider - the bot's enhancement provider.
|
|
169
233
|
* @param conversationType - the inbound message's scope.
|
|
170
|
-
* @param source - factory for the currently selected source fields
|
|
234
|
+
* @param source - factory for the currently selected source fields; attach the
|
|
235
|
+
* moment with `withSentAt` to publish a platform send time.
|
|
171
236
|
* @returns the enhancement to apply, or null when the scope is off.
|
|
172
237
|
*/
|
|
173
238
|
export function captureContextEnhancementSource(provider, conversationType, source) {
|
|
239
|
+
// No provider means enhancement is not configured for this channel at all, so
|
|
240
|
+
// there is nothing to capture -- and the thunk a channel published must not be
|
|
241
|
+
// evaluated. Answering null here is what lets every channel call this
|
|
242
|
+
// unconditionally, including bots with no enhancement settings.
|
|
243
|
+
if (typeof provider?.getSettings !== 'function') return null;
|
|
174
244
|
const snapshot = captureContextEnhancement(provider, conversationType);
|
|
175
|
-
|
|
245
|
+
if (snapshot === null) return null;
|
|
246
|
+
// Nothing is read here -- not the message's fields and not the moment a
|
|
247
|
+
// channel published through `withSentAt`. Local commands capture too (to keep
|
|
248
|
+
// the privacy rules for a later prompt) but never render, and a capture whose
|
|
249
|
+
// scope does not select `sentAt` must never touch the clock; the moment is
|
|
250
|
+
// evaluated only by the render that actually needs it.
|
|
251
|
+
return Object.freeze({ snapshot, source });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Accept a millisecond epoch or an ISO-8601 string; anything else yields null. */
|
|
255
|
+
function timestampMs(value) {
|
|
256
|
+
if (value === undefined || value === null || value === '') return null;
|
|
257
|
+
if (typeof value === 'bigint') value = Number(value);
|
|
258
|
+
if (typeof value === 'number') return Number.isSafeInteger(value) ? value : null;
|
|
259
|
+
if (typeof value === 'string') {
|
|
260
|
+
const parsed = Date.parse(value);
|
|
261
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Render one epoch instant as `YYYY-MM-DD HH:mm:ss` in local time. The zone is
|
|
268
|
+
* deliberately omitted: four independent components already carry the digits,
|
|
269
|
+
* and the guidance text is the place to name the timezone.
|
|
270
|
+
*
|
|
271
|
+
* Padding is spelled out per component instead of calling a module-level
|
|
272
|
+
* helper: `sourceBlock` can run while this module is still initializing, and
|
|
273
|
+
* reading a `const` declared below would throw a temporal-dead-zone error that
|
|
274
|
+
* the caller's catch silently swallows -- dropping the entire prefix.
|
|
275
|
+
*/
|
|
276
|
+
function formatTimestamp(ms) {
|
|
277
|
+
const date = new Date(ms);
|
|
278
|
+
const year = date.getFullYear();
|
|
279
|
+
if (!Number.isInteger(year)) return undefined;
|
|
280
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
281
|
+
return `${year}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
282
|
+
+ ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
176
283
|
}
|
|
177
284
|
|
|
178
285
|
function sourceString(value, field) {
|
|
@@ -193,6 +300,25 @@ function sourceBlock(snapshot, sourceFactory) {
|
|
|
193
300
|
const source = needsSource ? sourceFactory?.() : null;
|
|
194
301
|
const projected = {};
|
|
195
302
|
for (const field of fields) {
|
|
303
|
+
if (field === 'sentAt') {
|
|
304
|
+
// Only a channel that published a moment through `withSentAt` renders
|
|
305
|
+
// this field, and the moment is evaluated here, the first and only time
|
|
306
|
+
// it is needed. A field the factory returns is never read for it: a
|
|
307
|
+
// channel that did not wire a moment omits the field rather than fall
|
|
308
|
+
// back to the local clock, which the model could not tell apart.
|
|
309
|
+
const { published, ms } = readPublishedSentAt(sourceFactory);
|
|
310
|
+
const formatted = ms === undefined ? undefined : formatTimestamp(ms);
|
|
311
|
+
if (typeof formatted === 'string' && formatted) {
|
|
312
|
+
projected[field] = formatted.slice(0, SOURCE_LIMITS[field]);
|
|
313
|
+
} else if (published && !warnedMissingSentAt) {
|
|
314
|
+
// The channel did publish a moment but it was unusable or failed to
|
|
315
|
+
// read: say so once, because the omitted field is otherwise
|
|
316
|
+
// indistinguishable from a channel that supplies none.
|
|
317
|
+
warnedMissingSentAt = true;
|
|
318
|
+
console.warn('[dsh-im] a published sentAt was unusable and was omitted from the block');
|
|
319
|
+
}
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
196
322
|
const value = field === 'botId' || field === 'conversationType'
|
|
197
323
|
? snapshot[field] : source?.[field];
|
|
198
324
|
const normalized = sourceString(value, field);
|
|
@@ -213,11 +339,26 @@ function guidanceBlock(guidance) {
|
|
|
213
339
|
return `${INJECTED_CONTEXT_TAGS.guidanceOpen}\n${body}\n${INJECTED_CONTEXT_TAGS.guidanceClose}`;
|
|
214
340
|
}
|
|
215
341
|
|
|
216
|
-
/**
|
|
342
|
+
/**
|
|
343
|
+
* Add one text prefix; never inspect sources, format or copy content when off.
|
|
344
|
+
*
|
|
345
|
+
* @param content - the message body the channel is about to send.
|
|
346
|
+
* @param snapshot - either a capture from `captureContextEnhancementSource`
|
|
347
|
+
* (which already carries the moment and its own source) or a bare scope
|
|
348
|
+
* snapshot from `captureContextEnhancement`.
|
|
349
|
+
* @param sourceFactory - the channel's source factory, when the caller holds it
|
|
350
|
+
* separately from the capture. Omit it for a capture: the capture's own
|
|
351
|
+
* factory and moment are used, so a `sentAt` selection cannot be lost by
|
|
352
|
+
* passing only its `.snapshot`.
|
|
353
|
+
*/
|
|
217
354
|
export function enhanceContextContent(content, snapshot, sourceFactory) {
|
|
218
355
|
if (!snapshot) return content;
|
|
356
|
+
// A capture is `{snapshot, source}`; a bare scope is `{config, ...}`.
|
|
357
|
+
const scope = snapshot.config ? snapshot : snapshot.snapshot;
|
|
358
|
+
if (!scope?.config) return content;
|
|
359
|
+
const factory = sourceFactory ?? (snapshot.config ? undefined : snapshot.source);
|
|
219
360
|
try {
|
|
220
|
-
const blocks = [sourceBlock(
|
|
361
|
+
const blocks = [sourceBlock(scope, factory), guidanceBlock(scope.config.guidance)]
|
|
221
362
|
.filter(Boolean);
|
|
222
363
|
if (blocks.length === 0) return content;
|
|
223
364
|
const prefix = blocks.join(INJECTED_CONTEXT_SEPARATOR);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// English translations (feishu area). Keys are exact Chinese literals passed to t().
|
|
2
2
|
export default {
|
|
3
|
+
'已结束本轮提问,将在当前会话中继续处理图片。':
|
|
4
|
+
'The questions have been closed. Your image will be processed in this conversation.',
|
|
5
|
+
'图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。':
|
|
6
|
+
'Your image is queued, but the current questions could not be closed. Answer them or send /stop; your image will be processed afterward.',
|
|
7
|
+
'⏹ 提问已结束{progress}': '⏹ Questions closed{progress}',
|
|
3
8
|
'内容已合并至后续消息。': 'Content has been consolidated into the following messages.',
|
|
4
9
|
// feishu/bridge.mjs — welcome / help
|
|
5
10
|
'北汇星河 AIOS 已连接 DeepSeek Harness。':
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
captureContextEnhancement,
|
|
41
41
|
captureContextEnhancementSource,
|
|
42
42
|
enhanceContextContent,
|
|
43
|
+
withSentAt,
|
|
43
44
|
} from '../shared/context-enhancement.mjs';
|
|
44
45
|
import {
|
|
45
46
|
hasInboundImages,
|
|
@@ -398,9 +399,20 @@ export class WeixinHarnessBridge {
|
|
|
398
399
|
this.#acceptedMessageIds.set(messageId, null);
|
|
399
400
|
return this.#finishAccessDecision(messageId, sender, message, access);
|
|
400
401
|
}
|
|
401
|
-
|
|
402
|
+
// Capture through the *source* entry point, not the bare config snapshot:
|
|
403
|
+
// the enhancement that renders the user message is read back from this map
|
|
404
|
+
// at prompt time, and a rendered `sentAt` selection needs the factory that
|
|
405
|
+
// carries the moment. Nothing is read here; the thunk runs only when that
|
|
406
|
+
// render selects `sentAt`.
|
|
407
|
+
this.#acceptedMessageIds.set(messageId, captureContextEnhancementSource(
|
|
402
408
|
this.#contextEnhancement,
|
|
403
409
|
'direct',
|
|
410
|
+
withSentAt(
|
|
411
|
+
() => ({ channel: 'weixin', senderId: sender, chatId: sender }),
|
|
412
|
+
// A thunk: the platform send time (falling back to the iLink message-id
|
|
413
|
+
// clock) is only decoded when a `sentAt` selection actually renders.
|
|
414
|
+
() => message?.create_time_ms ?? weixinMessageTimestampMs(messageId),
|
|
415
|
+
),
|
|
404
416
|
));
|
|
405
417
|
if (sender === this.#ownerUserId) {
|
|
406
418
|
rememberConnectionTestTarget(this.#state, { toUserId: sender });
|
|
@@ -717,7 +729,14 @@ export class WeixinHarnessBridge {
|
|
|
717
729
|
enhancement: captureContextEnhancementSource(
|
|
718
730
|
this.#contextEnhancement,
|
|
719
731
|
'direct',
|
|
720
|
-
|
|
732
|
+
// The moment rides on the factory as a thunk, so a local command that
|
|
733
|
+
// never renders a source block -- and a scope that is off or does not
|
|
734
|
+
// select `sentAt` -- reads nothing from the inbound message.
|
|
735
|
+
withSentAt(
|
|
736
|
+
() => ({ channel: 'weixin', senderId: sender, chatId: sender }),
|
|
737
|
+
// Platform send time, falling back to the iLink message-id clock.
|
|
738
|
+
() => message?.create_time_ms ?? weixinMessageTimestampMs(messageId),
|
|
739
|
+
),
|
|
721
740
|
),
|
|
722
741
|
});
|
|
723
742
|
if (result?.stopped) {
|
|
@@ -817,15 +836,14 @@ export class WeixinHarnessBridge {
|
|
|
817
836
|
let content = hasImages || hasReply
|
|
818
837
|
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal, deferImages: true })
|
|
819
838
|
: undefined;
|
|
820
|
-
const
|
|
839
|
+
const captured = this.#acceptedMessageIds.get(messageId);
|
|
821
840
|
let contextEnhanced = false;
|
|
822
|
-
if (
|
|
841
|
+
if (captured) {
|
|
823
842
|
const originalContent = content ?? text;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
}));
|
|
843
|
+
// The capture already carries the moment its own source published, so
|
|
844
|
+
// `enhanceContextContent` accepts it as-is: passing only `.snapshot`
|
|
845
|
+
// here would drop `sentAt` and silently render no block at all.
|
|
846
|
+
content = enhanceContextContent(originalContent, captured);
|
|
829
847
|
contextEnhanced = content !== originalContent;
|
|
830
848
|
}
|
|
831
849
|
await this.#state.markSeen(messageId);
|
|
@@ -838,7 +856,7 @@ export class WeixinHarnessBridge {
|
|
|
838
856
|
text,
|
|
839
857
|
content,
|
|
840
858
|
titleText: batchSubmission?.title,
|
|
841
|
-
sourceGuidance: snapshot?.config?.guidance,
|
|
859
|
+
sourceGuidance: captured?.snapshot?.config?.guidance,
|
|
842
860
|
contextEnhanced,
|
|
843
861
|
createOptions: { signal: this.#signal },
|
|
844
862
|
existsOptions: { signal: this.#signal },
|