@xmanrui/dsh-im 4.30.0 → 4.32.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 +4 -3
- package/README.md +4 -3
- package/docs/client-integration.md +4 -0
- package/lib/client.js +111 -21
- package/lib/index.js +276 -276
- package/locale/en.json +5 -0
- package/locale/zh.json +5 -0
- package/package.json +10 -4
- package/plugin-src/client/context-enhancement.js +7 -0
- package/plugin-src/client/i18n.js +7 -1
- package/plugin-src/client/index.js +3 -0
- package/plugin-src/client/plugin-page.js +51 -0
- package/plugin-src/client/styles.js +9 -0
- package/scripts/verify-package.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +50 -3
- package/src/channels/feishu/feishu-cards.mjs +7 -1
- 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
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.32.0",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"deepseek-harness",
|
|
8
8
|
"dsh",
|
|
@@ -90,6 +90,10 @@
|
|
|
90
90
|
{
|
|
91
91
|
"name": "Biertolovisk",
|
|
92
92
|
"url": "https://github.com/rosalynthompson32-a11y"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"name": "Tianjun Cai",
|
|
96
|
+
"url": "https://github.com/Pegasus02"
|
|
93
97
|
}
|
|
94
98
|
],
|
|
95
99
|
"license": "MIT",
|
|
@@ -112,13 +116,15 @@
|
|
|
112
116
|
"exports": {
|
|
113
117
|
".": "./lib/index.js",
|
|
114
118
|
"./client": "./lib/client.js",
|
|
115
|
-
"./package.json": "./package.json"
|
|
119
|
+
"./package.json": "./package.json",
|
|
120
|
+
"./locale/*.json": "./locale/*.json"
|
|
116
121
|
},
|
|
117
122
|
"files": [
|
|
118
123
|
"assets",
|
|
119
124
|
"bin",
|
|
120
125
|
"cordis.patch.yml",
|
|
121
126
|
"lib",
|
|
127
|
+
"locale/*.json",
|
|
122
128
|
"plugin-src",
|
|
123
129
|
"scripts/verify-interface-language.mjs",
|
|
124
130
|
"scripts/verify-injected-context.mjs",
|
|
@@ -178,7 +184,7 @@
|
|
|
178
184
|
"mailparser": "^3.9.28",
|
|
179
185
|
"nodemailer": "^10.0.10",
|
|
180
186
|
"qrcode": "1.5.4",
|
|
181
|
-
"undici": "7.29.
|
|
187
|
+
"undici": "7.29.1"
|
|
182
188
|
},
|
|
183
189
|
"devDependencies": {
|
|
184
190
|
"@deepseek-ai/cordis": "4.0.1",
|
|
@@ -20,6 +20,7 @@ const FIELD_LABELS = Object.freeze({
|
|
|
20
20
|
chatId: '会话标识',
|
|
21
21
|
threadId: '话题标识',
|
|
22
22
|
botId: '机器人标识',
|
|
23
|
+
sentAt: '发送时间',
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
const FIELD_HELP = Object.freeze({
|
|
@@ -39,6 +40,10 @@ const FIELD_HELP = Object.freeze({
|
|
|
39
40
|
labelKey: 'threadIdHelpLabel',
|
|
40
41
|
text: '该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 ID,用于区分同一群组内的不同话题;当前消息不在话题中时,即使已选择该字段,<dsh_im_source> 中也会省略 threadId。',
|
|
41
42
|
}),
|
|
43
|
+
sentAt: Object.freeze({
|
|
44
|
+
labelKey: 'sentAtHelpLabel',
|
|
45
|
+
text: '当前消息的发送时间,格式为 YYYY-MM-DD HH:mm:ss(本机时区)。优先使用平台给出的发送时间;平台未提供时,从微信消息 ID 中解析出发送时间;两者都不可用时,即使已选择该字段,<dsh_im_source> 中也会省略 sentAt。目前仅微信渠道提供;把本机时区写进增强提示词,模型判断先后顺序时更准确。',
|
|
46
|
+
}),
|
|
42
47
|
});
|
|
43
48
|
|
|
44
49
|
const SCOPE_COPY = Object.freeze({
|
|
@@ -50,6 +55,7 @@ const SCOPE_COPY = Object.freeze({
|
|
|
50
55
|
conversationTitleHelpLabel: '查看群聊会话标题字段说明',
|
|
51
56
|
chatIdHelpLabel: '查看群聊会话标识字段说明',
|
|
52
57
|
threadIdHelpLabel: '查看群聊话题标识字段说明',
|
|
58
|
+
sentAtHelpLabel: '查看群聊发送时间字段说明',
|
|
53
59
|
guidanceLabel: '增强提示词',
|
|
54
60
|
guidanceHelpLabel: '查看群聊增强提示词使用说明',
|
|
55
61
|
guidanceUsage: '用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
|
@@ -63,6 +69,7 @@ const SCOPE_COPY = Object.freeze({
|
|
|
63
69
|
conversationTitleHelpLabel: '查看私聊会话标题字段说明',
|
|
64
70
|
chatIdHelpLabel: '查看私聊会话标识字段说明',
|
|
65
71
|
threadIdHelpLabel: '查看私聊话题标识字段说明',
|
|
72
|
+
sentAtHelpLabel: '查看私聊发送时间字段说明',
|
|
66
73
|
guidanceLabel: '增强提示词',
|
|
67
74
|
guidanceHelpLabel: '查看私聊增强提示词使用说明',
|
|
68
75
|
guidanceUsage: '用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
|
@@ -42,6 +42,8 @@ const EN = Object.freeze({
|
|
|
42
42
|
'无需安装第三方服务;请按指引授予 macOS Messages 访问权限。': 'No third-party service is required; follow the guide to grant macOS Messages access.',
|
|
43
43
|
'macOS Messages 权限': 'macOS Messages permissions',
|
|
44
44
|
'IM机器人': 'IM bots',
|
|
45
|
+
'打开 IM机器人': 'Open IM bots',
|
|
46
|
+
'返回插件详情': 'Back to plugin details',
|
|
45
47
|
'IM机器人设置': 'IM bot settings',
|
|
46
48
|
'IM 面板加载失败': 'Could not load the IM panel',
|
|
47
49
|
'更多机器人设置': 'More bot settings',
|
|
@@ -420,6 +422,10 @@ const EN = Object.freeze({
|
|
|
420
422
|
'查看私聊话题标识字段说明': 'View direct topic ID field details',
|
|
421
423
|
'该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 ID,用于区分同一群组内的不同话题;当前消息不在话题中时,即使已选择该字段,<dsh_im_source> 中也会省略 threadId。': 'This field is not available on every channel. Messages in a Feishu topic chat carry the topic ID, which distinguishes different topics inside the same group. If the current message is not inside a topic, <dsh_im_source> omits threadId even when the field is selected.',
|
|
422
424
|
'机器人标识': 'Bot ID',
|
|
425
|
+
'发送时间': 'Sent at',
|
|
426
|
+
'查看群聊发送时间字段说明': 'View group sent-at field details',
|
|
427
|
+
'查看私聊发送时间字段说明': 'View direct sent-at field details',
|
|
428
|
+
'当前消息的发送时间,格式为 YYYY-MM-DD HH:mm:ss(本机时区)。优先使用平台给出的发送时间;平台未提供时,从微信消息 ID 中解析出发送时间;两者都不可用时,即使已选择该字段,<dsh_im_source> 中也会省略 sentAt。目前仅微信渠道提供;把本机时区写进增强提示词,模型判断先后顺序时更准确。': 'When the current message was sent, as YYYY-MM-DD HH:mm:ss in the local time zone. The platform send time is used when available; otherwise the send time is decoded from the WeChat message ID. If neither is available, <dsh_im_source> omits sentAt even when the field is selected. WeChat is the only channel that provides it today. Naming your time zone in the guidance makes ordering judgements more accurate.',
|
|
423
429
|
'增强提示词': 'Guidance',
|
|
424
430
|
'查看增强提示词使用说明': 'View guidance instructions',
|
|
425
431
|
'查看群聊增强提示词使用说明': 'View group guidance instructions',
|
|
@@ -446,7 +452,7 @@ const EN = Object.freeze({
|
|
|
446
452
|
'上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
|
|
447
453
|
'请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
|
|
448
454
|
'群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
|
|
449
|
-
'
|
|
455
|
+
'来源字段只能选择已定义的字段。': 'Source fields must be chosen from the defined fields.',
|
|
450
456
|
'保存中…': 'Saving…',
|
|
451
457
|
'未设置': 'Not set',
|
|
452
458
|
'工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
|
|
@@ -83,6 +83,7 @@ import { installSessionChannelLogos } from './session-channel-logos.js';
|
|
|
83
83
|
import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
|
|
84
84
|
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
|
|
85
85
|
import { IMPanelErrorBoundary } from './panel-error-boundary.js';
|
|
86
|
+
import { installImPluginPage } from './plugin-page.js';
|
|
86
87
|
|
|
87
88
|
export const name = 'im-settings';
|
|
88
89
|
export const inject = ['slots', 'connection', 'locale', 'workspaces'];
|
|
@@ -566,6 +567,8 @@ export function apply(ctx) {
|
|
|
566
567
|
preferredSectionId: props.preferredSectionId,
|
|
567
568
|
});
|
|
568
569
|
|
|
570
|
+
installImPluginPage(ctx, { packageName: manifest.name, renderPanel: buildPanelElement });
|
|
571
|
+
|
|
569
572
|
ctx.effect(() => {
|
|
570
573
|
let disposed = false;
|
|
571
574
|
let stopSettings = null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { IM_LOCALE_NAMESPACE } from './i18n.js';
|
|
3
|
+
|
|
4
|
+
export const IM_MAIN_PANEL_ID = 'xmanrui-dsh-im';
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
function OpenImAction({ subject, packageName, onOpen, t }) {
|
|
8
|
+
if (subject?.kind !== 'bundle' && subject?.kind !== 'row') return null;
|
|
9
|
+
if (subject.pkg?.name !== packageName) return null;
|
|
10
|
+
if (subject.kind === 'row' && subject.row?.moduleName !== packageName) return null;
|
|
11
|
+
return h('button', {
|
|
12
|
+
type: 'button', className: 'dim-pluginOpen', onClick: onOpen,
|
|
13
|
+
}, t('打开 IM机器人'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ImPluginPage({ renderPanel, onBack, t }) {
|
|
17
|
+
return h('div', { className: 'dim-pluginPage' },
|
|
18
|
+
h('div', { className: 'dim-pluginPageContent' },
|
|
19
|
+
h('button', { type: 'button', className: 'dim-pluginBack', onClick: onBack },
|
|
20
|
+
h('span', { 'aria-hidden': true }, '‹'), t('返回插件详情')),
|
|
21
|
+
renderPanel()));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Optional services and slots keep the existing settings entry working on
|
|
25
|
+
// older DSH versions. Reuse the same panel through public navigation APIs.
|
|
26
|
+
export function installImPluginPage(ctx, { packageName, renderPanel }) {
|
|
27
|
+
if (typeof ctx.inject !== 'function') return;
|
|
28
|
+
ctx.inject(['layout', 'pluginNavigation'], (pageCtx) =>
|
|
29
|
+
pageCtx.slots.inject('main', () => {
|
|
30
|
+
const unregisterPage = pageCtx.slots.register({
|
|
31
|
+
name: 'main', key: IM_MAIN_PANEL_ID, locale: IM_LOCALE_NAMESPACE,
|
|
32
|
+
inject: () => ({
|
|
33
|
+
renderPanel,
|
|
34
|
+
onBack: () => pageCtx.pluginNavigation.openBundle(packageName),
|
|
35
|
+
}),
|
|
36
|
+
}, ImPluginPage);
|
|
37
|
+
const stopActions = pageCtx.slots.inject('plugins.detail.actions', () =>
|
|
38
|
+
pageCtx.slots.register({
|
|
39
|
+
name: 'plugins.detail.actions', id: 'xmanrui-dsh-im-open',
|
|
40
|
+
order: 20, locale: IM_LOCALE_NAMESPACE,
|
|
41
|
+
inject: () => ({
|
|
42
|
+
packageName,
|
|
43
|
+
onOpen: () => pageCtx.layout.selectPanel(IM_MAIN_PANEL_ID),
|
|
44
|
+
}),
|
|
45
|
+
}, OpenImAction));
|
|
46
|
+
return () => {
|
|
47
|
+
stopActions();
|
|
48
|
+
unregisterPage();
|
|
49
|
+
};
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
export const IM_STYLE_ID = 'xmanrui-dsh-im-settings';
|
|
2
2
|
|
|
3
3
|
const CSS = String.raw`
|
|
4
|
+
.dim-pluginOpen { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 7px 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
|
|
5
|
+
.dim-pluginOpen:hover { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
|
6
|
+
.dim-pluginOpen:focus-visible, .dim-pluginBack:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 3px; }
|
|
7
|
+
.dim-pluginPage { height: 100%; min-height: 0; overflow: auto; box-sizing: border-box; padding: 24px clamp(16px, 4vw, 48px) 32px; color: var(--dsw-alias-label-primary, #1f2329); }
|
|
8
|
+
.dim-pluginPageContent { max-width: 1080px; margin: 0 auto; }
|
|
9
|
+
.dim-pluginBack { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; margin: 0 0 24px; padding: 6px 8px; border: 0; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
|
|
10
|
+
.dim-pluginBack:hover { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
|
11
|
+
.dim-pluginBack > span { font-size: 22px; line-height: 20px; }
|
|
12
|
+
@media (pointer: coarse) { .dim-pluginOpen, .dim-pluginBack { min-height: 44px; } }
|
|
4
13
|
.dim-connectionDiagnostic { min-width: 0; width: 100%; color: var(--dsw-alias-label-secondary, #646a73); overflow-wrap: anywhere; font-size: 13px; line-height: 1.6; }
|
|
5
14
|
.dim-connectionDiagnostic[data-warning="true"] { color: var(--dsw-alias-state-warn-primary, #d97706); }
|
|
6
15
|
.dim-connectionDiagnostic p { margin: 4px 0; }
|
|
@@ -219,7 +219,7 @@ const directDependencies = {
|
|
|
219
219
|
'@tencent-connect/qqbot-nodejs': '1.0.4',
|
|
220
220
|
'@wecom/aibot-node-sdk': '1.0.7',
|
|
221
221
|
qrcode: '1.5.4',
|
|
222
|
-
undici: '7.29.
|
|
222
|
+
undici: '7.29.1',
|
|
223
223
|
};
|
|
224
224
|
for (const [name, version] of Object.entries(directDependencies)) {
|
|
225
225
|
if (manifest.dependencies?.[name] !== version) {
|
|
@@ -1264,7 +1264,7 @@ export class FeishuHarnessBridge {
|
|
|
1264
1264
|
this.#commandTasks.add(current);
|
|
1265
1265
|
return current;
|
|
1266
1266
|
}
|
|
1267
|
-
if (this.#isResolvedQuestionReply(event, key)) {
|
|
1267
|
+
if (!hasImages && this.#isResolvedQuestionReply(event, key)) {
|
|
1268
1268
|
const current = Promise.resolve()
|
|
1269
1269
|
.then(() => this.#discardResolvedInteractionReply(event, messageId))
|
|
1270
1270
|
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
|
@@ -1317,6 +1317,19 @@ export class FeishuHarnessBridge {
|
|
|
1317
1317
|
if (pending && senderOpenId(event) !== pending.actor) {
|
|
1318
1318
|
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
1319
1319
|
}
|
|
1320
|
+
if (pending && hasImages) {
|
|
1321
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
1322
|
+
const interruption = previous.catch(() => undefined)
|
|
1323
|
+
.then(() => this.#interruptQuestionForImage(event, key, pending))
|
|
1324
|
+
.finally(() => {
|
|
1325
|
+
if (pending.queue === interruption) pending.queue = null;
|
|
1326
|
+
this.#interactionTasks.delete(interruption);
|
|
1327
|
+
});
|
|
1328
|
+
pending.queue = interruption;
|
|
1329
|
+
this.#interactionTasks.add(interruption);
|
|
1330
|
+
// Reserve the image's FIFO position before stopping releases the original turn.
|
|
1331
|
+
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
1332
|
+
}
|
|
1320
1333
|
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
1321
1334
|
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
1322
1335
|
}
|
|
@@ -5809,6 +5822,40 @@ export class FeishuHarnessBridge {
|
|
|
5809
5822
|
});
|
|
5810
5823
|
}
|
|
5811
5824
|
|
|
5825
|
+
async #interruptQuestionForImage(event, key, pending) {
|
|
5826
|
+
if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
|
|
5827
|
+
const question = pending.questions[pending.index];
|
|
5828
|
+
const questionMessageId = pending.questionCardMessageId;
|
|
5829
|
+
const index = pending.index;
|
|
5830
|
+
const wasSubmitting = pending.submitting;
|
|
5831
|
+
pending.submitting = true;
|
|
5832
|
+
try {
|
|
5833
|
+
await runControlCommand('/stop', this.#harness, this.#state, key, {
|
|
5834
|
+
signal: this.#signal,
|
|
5835
|
+
control: { owner: this, key },
|
|
5836
|
+
});
|
|
5837
|
+
} catch (error) {
|
|
5838
|
+
if (this.#pendingInteractions.get(key) === pending) pending.submitting = wasSubmitting;
|
|
5839
|
+
this.#logger.warn?.('[dsh-feishu] could not stop the question turn for an image:', error.message);
|
|
5840
|
+
await this.#send(event.message.chat_id,
|
|
5841
|
+
t('图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。'),
|
|
5842
|
+
{ replyTo: event.message.message_id }).catch(() => undefined);
|
|
5843
|
+
return;
|
|
5844
|
+
}
|
|
5845
|
+
await this.#cancelPendingInteraction(key, pending.interactionId);
|
|
5846
|
+
const resolvedText = t('已结束本轮提问,将在当前会话中继续处理图片。');
|
|
5847
|
+
if (questionMessageId) {
|
|
5848
|
+
await this.#patchCardMessage(event.message.chat_id, questionMessageId, questionCard({
|
|
5849
|
+
...question,
|
|
5850
|
+
interactionId: pending.interactionId,
|
|
5851
|
+
index,
|
|
5852
|
+
total: pending.questions.length,
|
|
5853
|
+
resolvedText,
|
|
5854
|
+
}));
|
|
5855
|
+
}
|
|
5856
|
+
await this.#send(event.message.chat_id, resolvedText, { replyTo: event.message.message_id }).catch(() => undefined);
|
|
5857
|
+
}
|
|
5858
|
+
|
|
5812
5859
|
async #submitQuestionAnswer(pending, answerText, { chatId, messageId, questionMessageId } = {}) {
|
|
5813
5860
|
const question = pending.questions[pending.index];
|
|
5814
5861
|
if (!question) return;
|
|
@@ -6146,8 +6193,8 @@ export class FeishuHarnessBridge {
|
|
|
6146
6193
|
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
6147
6194
|
}
|
|
6148
6195
|
|
|
6149
|
-
async #cancelPendingInteraction(key) {
|
|
6150
|
-
const pending = this.#takePendingInteraction(key);
|
|
6196
|
+
async #cancelPendingInteraction(key, interactionId) {
|
|
6197
|
+
const pending = this.#takePendingInteraction(key, interactionId);
|
|
6151
6198
|
if (!pending || pending.kind !== 'question') return;
|
|
6152
6199
|
this.#rememberResolvedInteraction(key, pending);
|
|
6153
6200
|
try {
|
|
@@ -1211,9 +1211,10 @@ export function splitStepStreamCardBlocks(
|
|
|
1211
1211
|
* rendered as its own button; the selected option label is submitted via a
|
|
1212
1212
|
* card callback. Multi-select questions fall back to the plain-text flow (the
|
|
1213
1213
|
* caller decides), because a multi-select needs a confirm step.
|
|
1214
|
+
* A resolvedText replaces the buttons when an image ends the question flow.
|
|
1214
1215
|
* Action: answer:<interactionId>:<optionLabel>
|
|
1215
1216
|
*/
|
|
1216
|
-
export function questionCard({ interactionId, header, question, detail, options, index, total }) {
|
|
1217
|
+
export function questionCard({ interactionId, header, question, detail, options, index, total, resolvedText }) {
|
|
1217
1218
|
const elements = [];
|
|
1218
1219
|
const progress = total > 1 ? `(${index + 1}/${total})` : '';
|
|
1219
1220
|
if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
|
|
@@ -1221,6 +1222,11 @@ export function questionCard({ interactionId, header, question, detail, options,
|
|
|
1221
1222
|
elements.push({ tag: 'div', text: markdown(String(qText)) });
|
|
1222
1223
|
if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
|
|
1223
1224
|
|
|
1225
|
+
if (resolvedText) {
|
|
1226
|
+
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(resolvedText) });
|
|
1227
|
+
return cardWith(t('⏹ 提问已结束{progress}', { progress }), elements);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1224
1230
|
if (Array.isArray(options) && options.length > 0) {
|
|
1225
1231
|
elements.push({ tag: 'hr' });
|
|
1226
1232
|
for (const option of options) {
|
|
@@ -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 },
|