@xmanrui/dsh-im 4.7.0 → 4.9.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 -79
- package/README.md +8 -79
- package/lib/client.js +46 -8
- package/lib/index.js +221 -217
- package/package.json +5 -1
- package/plugin-src/client/context-enhancement.js +14 -0
- package/plugin-src/client/i18n.js +9 -1
- package/plugin-src/client/styles.js +3 -2
- package/plugin-src/client/workspace-directory-picker.js +14 -3
- package/src/channels/dingtalk/dingtalk-bridge.mjs +2 -0
- package/src/channels/feishu/bridge.mjs +176 -20
- package/src/channels/feishu/feishu-cards.mjs +69 -0
- package/src/channels/feishu/feishu-runtime.mjs +5 -0
- package/src/channels/feishu/slash-command-registry.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +2 -0
- package/src/channels/shared/context-enhancement.mjs +5 -4
- package/src/channels/shared/harness-approval.mjs +24 -1
- package/src/channels/shared/harness-client.mjs +45 -16
- package/src/channels/shared/i18n-en/feishu.mjs +11 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
- package/src/channels/shared/semantic/reply-reference.mjs +5 -11
- package/src/channels/shared/text-harness-bridge.mjs +6 -2
- package/src/channels/shared/workspace-command.mjs +2 -2
- package/src/channels/slack/slack-runtime.mjs +4 -0
- package/src/channels/telegram/telegram-runtime.mjs +8 -9
- package/src/channels/wecom/wecom-bridge.mjs +2 -0
- package/src/channels/weixin/weixin-bridge.mjs +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
4
4
|
"description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
{
|
|
35
35
|
"name": "divingleee",
|
|
36
36
|
"url": "https://github.com/divingleee"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "Chan-0312",
|
|
40
|
+
"url": "https://github.com/Chan-0312"
|
|
37
41
|
}
|
|
38
42
|
],
|
|
39
43
|
"license": "MIT",
|
|
@@ -17,6 +17,8 @@ const FIELD_LABELS = Object.freeze({
|
|
|
17
17
|
senderId: '发送者标识',
|
|
18
18
|
senderName: '发送者昵称',
|
|
19
19
|
conversationTitle: '会话标题',
|
|
20
|
+
chatId: '会话标识',
|
|
21
|
+
threadId: '话题标识',
|
|
20
22
|
botId: '机器人标识',
|
|
21
23
|
});
|
|
22
24
|
|
|
@@ -29,6 +31,14 @@ const FIELD_HELP = Object.freeze({
|
|
|
29
31
|
labelKey: 'conversationTitleHelpLabel',
|
|
30
32
|
text: '该字段不是每个渠道都能提供。钉钉群聊会带上群名。当前消息没有会话标题时,即使已选择该字段,<dsh_im_source> 中也会省略 conversationTitle。',
|
|
31
33
|
}),
|
|
34
|
+
chatId: Object.freeze({
|
|
35
|
+
labelKey: 'chatIdHelpLabel',
|
|
36
|
+
text: '该字段不是每个渠道都能提供。会话标识用于区分不同的群组或私聊,飞书群聊会带上群 ID。当前消息没有会话标识时,即使已选择该字段,<dsh_im_source> 中也会省略 chatId。',
|
|
37
|
+
}),
|
|
38
|
+
threadId: Object.freeze({
|
|
39
|
+
labelKey: 'threadIdHelpLabel',
|
|
40
|
+
text: '该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 ID,用于区分同一群组内的不同话题;当前消息不在话题中时,即使已选择该字段,<dsh_im_source> 中也会省略 threadId。',
|
|
41
|
+
}),
|
|
32
42
|
});
|
|
33
43
|
|
|
34
44
|
const SCOPE_COPY = Object.freeze({
|
|
@@ -38,6 +48,8 @@ const SCOPE_COPY = Object.freeze({
|
|
|
38
48
|
fieldsHelpLabel: '查看群聊来源字段说明',
|
|
39
49
|
senderNameHelpLabel: '查看群聊发送者昵称字段说明',
|
|
40
50
|
conversationTitleHelpLabel: '查看群聊会话标题字段说明',
|
|
51
|
+
chatIdHelpLabel: '查看群聊会话标识字段说明',
|
|
52
|
+
threadIdHelpLabel: '查看群聊话题标识字段说明',
|
|
41
53
|
guidanceLabel: '增强提示词',
|
|
42
54
|
guidanceHelpLabel: '查看群聊增强提示词使用说明',
|
|
43
55
|
guidanceUsage: '用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
|
@@ -49,6 +61,8 @@ const SCOPE_COPY = Object.freeze({
|
|
|
49
61
|
fieldsHelpLabel: '查看私聊来源字段说明',
|
|
50
62
|
senderNameHelpLabel: '查看私聊发送者昵称字段说明',
|
|
51
63
|
conversationTitleHelpLabel: '查看私聊会话标题字段说明',
|
|
64
|
+
chatIdHelpLabel: '查看私聊会话标识字段说明',
|
|
65
|
+
threadIdHelpLabel: '查看私聊话题标识字段说明',
|
|
52
66
|
guidanceLabel: '增强提示词',
|
|
53
67
|
guidanceHelpLabel: '查看私聊增强提示词使用说明',
|
|
54
68
|
guidanceUsage: '用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
|
@@ -292,6 +292,14 @@ const EN = Object.freeze({
|
|
|
292
292
|
'查看群聊会话标题字段说明': 'View group conversation title field details',
|
|
293
293
|
'查看私聊会话标题字段说明': 'View direct conversation title field details',
|
|
294
294
|
'该字段不是每个渠道都能提供。钉钉群聊会带上群名。当前消息没有会话标题时,即使已选择该字段,<dsh_im_source> 中也会省略 conversationTitle。': 'This field is not available on every channel. DingTalk group chats include the group name. If the current message has no conversation title, <dsh_im_source> omits conversationTitle even when the field is selected.',
|
|
295
|
+
'会话标识': 'Chat ID',
|
|
296
|
+
'查看群聊会话标识字段说明': 'View group chat ID field details',
|
|
297
|
+
'查看私聊会话标识字段说明': 'View direct chat ID field details',
|
|
298
|
+
'该字段不是每个渠道都能提供。会话标识用于区分不同的群组或私聊,飞书群聊会带上群 ID。当前消息没有会话标识时,即使已选择该字段,<dsh_im_source> 中也会省略 chatId。': 'This field is not available on every channel. The chat ID distinguishes different groups or direct chats, and Feishu group chats include the group ID. If the current message has no chat ID, <dsh_im_source> omits chatId even when the field is selected.',
|
|
299
|
+
'话题标识': 'Topic ID',
|
|
300
|
+
'查看群聊话题标识字段说明': 'View group topic ID field details',
|
|
301
|
+
'查看私聊话题标识字段说明': 'View direct topic ID field details',
|
|
302
|
+
'该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 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.',
|
|
295
303
|
'机器人标识': 'Bot ID',
|
|
296
304
|
'增强提示词': 'Guidance',
|
|
297
305
|
'查看增强提示词使用说明': 'View guidance instructions',
|
|
@@ -319,7 +327,7 @@ const EN = Object.freeze({
|
|
|
319
327
|
'上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
|
|
320
328
|
'请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
|
|
321
329
|
'群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
|
|
322
|
-
'
|
|
330
|
+
'来源字段只能选择已定义的八个字段。': 'Source fields must be chosen from the eight defined fields.',
|
|
323
331
|
'保存中…': 'Saving…',
|
|
324
332
|
'未设置': 'Not set',
|
|
325
333
|
'工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
|
|
@@ -245,14 +245,15 @@ const CSS = String.raw`
|
|
|
245
245
|
.dim-contextSwitch:checked { border-color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-state-business-primary, #3370ff); }
|
|
246
246
|
.dim-contextSwitch:checked::before { transform: translateX(13px); background: #fff; }
|
|
247
247
|
.dim-contextFields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 12px; }
|
|
248
|
-
.dim-contextField { min-width: 0; min-height: 30px; display: flex; align-items: center; gap: 6px; }
|
|
248
|
+
.dim-contextField { position: relative; min-width: 0; min-height: 30px; display: flex; align-items: center; gap: 6px; }
|
|
249
249
|
.dim-contextField input { flex: none; width: 14px; height: 14px; margin: 0; accent-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
|
250
250
|
.dim-contextFieldText { min-width: 0; display: grid; grid-template-columns: max-content max-content; align-items: center; column-gap: 5px; overflow-wrap: anywhere; }
|
|
251
251
|
.dim-contextFieldName { min-width: 0; line-height: 17px; cursor: pointer; }
|
|
252
252
|
.dim-contextFieldKey { min-width: 0; grid-column: 1 / -1; color: var(--dsw-alias-label-tertiary, #8f959e); font: 10px/14px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; cursor: pointer; }
|
|
253
|
-
.dim-contextFieldHelp { position:
|
|
253
|
+
.dim-contextFieldHelp { position: static; }
|
|
254
254
|
.dim-contextFieldHelpButton { width: 16px; height: 16px; font-size: 10px; }
|
|
255
255
|
.dim-contextTooltip.dim-contextFieldTooltip { top: calc(100% + 6px); right: 0; left: auto; width: min(280px, calc(100vw - 72px)); }
|
|
256
|
+
.dim-contextField:nth-child(odd) .dim-contextFieldTooltip { right: auto; left: 0; }
|
|
256
257
|
.dim-contextEditorHeader { position: relative; flex-wrap: wrap; }
|
|
257
258
|
.dim-contextEditorTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
|
|
258
259
|
.dim-contextEditorTitle > label { font-weight: 500; }
|
|
@@ -7,6 +7,17 @@ function pickerErrorCode(error) {
|
|
|
7
7
|
return error?.rpcError?.code ?? error?.code;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const PICKER_ERROR_KINDS = new Map([
|
|
11
|
+
['directory-picker/unavailable', 'unavailable'],
|
|
12
|
+
['directory-picker-unavailable', 'unavailable'],
|
|
13
|
+
['directory-picker/unreadable', 'unreadable'],
|
|
14
|
+
['directory-unreadable', 'unreadable'],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function pickerErrorKind(error) {
|
|
18
|
+
return PICKER_ERROR_KINDS.get(pickerErrorCode(error));
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
function pickerErrorDetails(error) {
|
|
11
22
|
return error?.rpcError?.details ?? error?.details;
|
|
12
23
|
}
|
|
@@ -110,9 +121,9 @@ export function WorkspaceDirectoryPicker({
|
|
|
110
121
|
const initialPath = initialPathRef.current;
|
|
111
122
|
const initial = await loadDirectory(initialPath || undefined, { reportError: false });
|
|
112
123
|
if (!active || initial.aborted || initial.value) return;
|
|
113
|
-
const
|
|
124
|
+
const kind = pickerErrorKind(initial.error);
|
|
114
125
|
const details = pickerErrorDetails(initial.error);
|
|
115
|
-
if (
|
|
126
|
+
if (kind === 'unavailable'
|
|
116
127
|
&& details?.capability === 'native'
|
|
117
128
|
&& typeof picker.pickDirectory === 'function') {
|
|
118
129
|
setLoading(true);
|
|
@@ -128,7 +139,7 @@ export function WorkspaceDirectoryPicker({
|
|
|
128
139
|
}
|
|
129
140
|
return;
|
|
130
141
|
}
|
|
131
|
-
if (initialPath &&
|
|
142
|
+
if (initialPath && kind === 'unreadable') {
|
|
132
143
|
const home = await loadDirectory(undefined, { reportError: false });
|
|
133
144
|
if (!active || home.aborted || home.value) return;
|
|
134
145
|
setError(pickerErrorMessage(home.error));
|
|
@@ -81,6 +81,7 @@ const HELP_TEXT_LINES = [
|
|
|
81
81
|
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
|
|
82
82
|
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
83
83
|
'/workspacelist 列出工作区绝对路径',
|
|
84
|
+
'/ws、/wsl、/workspaces 工作区命令别名',
|
|
84
85
|
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
85
86
|
'/sessionlist --limit N 仅列出当前工作区前 N 个会话',
|
|
86
87
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
@@ -1136,6 +1137,7 @@ export class DingtalkHarnessBridge {
|
|
|
1136
1137
|
senderId: sender,
|
|
1137
1138
|
senderName: message.senderNick,
|
|
1138
1139
|
conversationTitle: message.conversationTitle,
|
|
1140
|
+
chatId: message.conversationId,
|
|
1139
1141
|
}));
|
|
1140
1142
|
contextEnhanced = content !== originalContent;
|
|
1141
1143
|
}
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
MENU_PAGE_SIZE,
|
|
77
77
|
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
|
78
78
|
STEER_CUSTOM_SENTINEL,
|
|
79
|
+
approvalCard,
|
|
79
80
|
completionCard,
|
|
80
81
|
customSteerCard,
|
|
81
82
|
helpCard,
|
|
@@ -83,6 +84,7 @@ import {
|
|
|
83
84
|
menuHelpText,
|
|
84
85
|
modelCard,
|
|
85
86
|
presetCard,
|
|
87
|
+
questionCard,
|
|
86
88
|
sessionListCard,
|
|
87
89
|
statusCard,
|
|
88
90
|
steerCard,
|
|
@@ -107,7 +109,7 @@ const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
|
|
|
107
109
|
const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
|
|
108
110
|
const WATCHLIST_COMMAND = /^\/watchlist$/i;
|
|
109
111
|
const SESSION_LIST_PREFIX = /^\/(?:sessionlist|sessions)(?:\s|$)/i;
|
|
110
|
-
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
|
112
|
+
const WORKSPACE_LIST_COMMAND = /^\/(?:workspacelist|workspaces|wsl)$/i;
|
|
111
113
|
const NUMBER_REPLY = /^\d{1,2}$/;
|
|
112
114
|
/** A displayed menu stays number-tappable for this long. */
|
|
113
115
|
const MENU_TTL_MS = 10 * 60_000;
|
|
@@ -146,7 +148,34 @@ const REPAIR_URL_HOSTS = new Set([
|
|
|
146
148
|
|
|
147
149
|
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
|
148
150
|
/** Matches fast card commands that should not be queued behind a running task. */
|
|
149
|
-
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
|
|
151
|
+
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
|
|
152
|
+
|
|
153
|
+
/** Pretty-print a tool call's arguments for an approval card. */
|
|
154
|
+
function operationArguments(toolCall) {
|
|
155
|
+
const source = toolCall?.arguments;
|
|
156
|
+
if (source !== null && typeof source === 'object') {
|
|
157
|
+
try {
|
|
158
|
+
return JSON.stringify(source, null, 2);
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (typeof source !== 'string') return null;
|
|
164
|
+
const raw = printableText(source);
|
|
165
|
+
// Harness treats an empty tool argument string as an empty object.
|
|
166
|
+
if (!raw) return source === '' ? '{}' : null;
|
|
167
|
+
try {
|
|
168
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
169
|
+
} catch {
|
|
170
|
+
return raw;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Strip control characters so the approval card text stays clean. */
|
|
175
|
+
function printableText(value) {
|
|
176
|
+
return String(value ?? '')
|
|
177
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
|
|
178
|
+
}
|
|
150
179
|
|
|
151
180
|
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
|
|
152
181
|
if (hasImages || hasFiles || typeof text !== 'string') return false;
|
|
@@ -471,6 +500,8 @@ export class FeishuHarnessBridge {
|
|
|
471
500
|
/** Earliest completion that still needs delivery for each watch. */
|
|
472
501
|
#failedWatchSeqs = new Map();
|
|
473
502
|
#cardDataTimeoutMs;
|
|
503
|
+
/** When true, approval/question interactions render as Feishu cards (buttons). */
|
|
504
|
+
#interactionCards = true;
|
|
474
505
|
|
|
475
506
|
constructor({
|
|
476
507
|
client,
|
|
@@ -490,6 +521,7 @@ export class FeishuHarnessBridge {
|
|
|
490
521
|
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
|
491
522
|
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
|
|
492
523
|
replyTimeoutMs = 600_000,
|
|
524
|
+
interactionCards = true,
|
|
493
525
|
logger = console,
|
|
494
526
|
signal,
|
|
495
527
|
}) {
|
|
@@ -528,6 +560,7 @@ export class FeishuHarnessBridge {
|
|
|
528
560
|
this.#repairLinkWaitMs = repairLinkWaitMs;
|
|
529
561
|
this.#cardDataTimeoutMs = cardDataTimeoutMs;
|
|
530
562
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
563
|
+
this.#interactionCards = interactionCards === true;
|
|
531
564
|
this.#logger = logger;
|
|
532
565
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
|
533
566
|
this.#signal = signal;
|
|
@@ -1644,10 +1677,15 @@ export class FeishuHarnessBridge {
|
|
|
1644
1677
|
}
|
|
1645
1678
|
const conversationType = route.key.startsWith('p2p:') ? 'direct'
|
|
1646
1679
|
: route.key.startsWith('group:') ? 'group' : null;
|
|
1680
|
+
const isInteractionResponse = resolvedAction.startsWith('approve:')
|
|
1681
|
+
|| resolvedAction.startsWith('reject:')
|
|
1682
|
+
|| resolvedAction.startsWith('answer:');
|
|
1647
1683
|
const access = evaluateInboundAccess(this.#accessPolicy, {
|
|
1648
1684
|
conversationType,
|
|
1649
1685
|
senderIds: operatorOpenId,
|
|
1650
|
-
|
|
1686
|
+
// These buttons are the card equivalent of an ordinary approval or
|
|
1687
|
+
// question reply. Every other card action remains command-gated.
|
|
1688
|
+
isCommand: !isInteractionResponse,
|
|
1651
1689
|
});
|
|
1652
1690
|
if (!access.allowed) {
|
|
1653
1691
|
if (access.reason === 'command-not-allowed') {
|
|
@@ -1710,7 +1748,7 @@ export class FeishuHarnessBridge {
|
|
|
1710
1748
|
return;
|
|
1711
1749
|
}
|
|
1712
1750
|
}
|
|
1713
|
-
await this.#handleCardAction(resolvedAction, entry);
|
|
1751
|
+
await this.#handleCardAction(resolvedAction, { ...entry, actor: entry.operatorOpenId });
|
|
1714
1752
|
}, {
|
|
1715
1753
|
lane: isStop || isRealSteer ? 'control' : 'regular',
|
|
1716
1754
|
coalesceStop: isStop,
|
|
@@ -1868,10 +1906,49 @@ export class FeishuHarnessBridge {
|
|
|
1868
1906
|
sessionPage = 0,
|
|
1869
1907
|
sessionLimit = null,
|
|
1870
1908
|
selections = [],
|
|
1909
|
+
actor = null,
|
|
1871
1910
|
}) {
|
|
1872
1911
|
// Confirmations triggered by a card interaction stay anchored to the
|
|
1873
1912
|
// card's message so they land inside the same Feishu topic.
|
|
1874
1913
|
const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
|
|
1914
|
+
// Approval card buttons: approve:<approvalId> / reject:<approvalId>
|
|
1915
|
+
if (action.startsWith('approve:') || action.startsWith('reject:')) {
|
|
1916
|
+
const sep = action.indexOf(':');
|
|
1917
|
+
const approvalId = action.slice(sep + 1);
|
|
1918
|
+
const outcome = action.startsWith('approve:') ? 'allowed-once' : 'rejected';
|
|
1919
|
+
// Bind the decision to the operator so another allowed group member
|
|
1920
|
+
// cannot decide someone else's approval.
|
|
1921
|
+
const submitted = await this.#approvals.submitByApprovalId(approvalId, outcome, { actor });
|
|
1922
|
+
if (!submitted) {
|
|
1923
|
+
await reply(t('该审批已处理或不存在,无需重复操作。')).catch(() => undefined);
|
|
1924
|
+
}
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
// Question option buttons: answer:<interactionId>:<index>:<optionLabel>
|
|
1928
|
+
if (action.startsWith('answer:')) {
|
|
1929
|
+
const rest = action.slice('answer:'.length);
|
|
1930
|
+
const firstSep = rest.indexOf(':');
|
|
1931
|
+
if (firstSep !== -1) {
|
|
1932
|
+
const interactionId = rest.slice(0, firstSep);
|
|
1933
|
+
const afterId = rest.slice(firstSep + 1);
|
|
1934
|
+
const indexSep = afterId.indexOf(':');
|
|
1935
|
+
const indexText = indexSep === -1 ? afterId : afterId.slice(0, indexSep);
|
|
1936
|
+
const optionLabel = indexSep === -1 ? '' : afterId.slice(indexSep + 1);
|
|
1937
|
+
const qKey = this.#interactionKeys.get(interactionId);
|
|
1938
|
+
const pending = qKey ? this.#pendingInteractions.get(qKey) : null;
|
|
1939
|
+
// Only the actor who started the interaction may answer it, and the
|
|
1940
|
+
// card must still target the current question (a stale card from an
|
|
1941
|
+
// earlier question in a multi-question interaction must not submit).
|
|
1942
|
+
if (pending && pending.kind === 'question' && !pending.submitting
|
|
1943
|
+
&& pending.actor === actor
|
|
1944
|
+
&& Number(indexText) === pending.index) {
|
|
1945
|
+
await this.#submitQuestionAnswer(pending, optionLabel, { chatId });
|
|
1946
|
+
} else {
|
|
1947
|
+
await reply(INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1875
1952
|
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
|
|
1876
1953
|
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
|
1877
1954
|
await this.#showSessions(
|
|
@@ -3330,6 +3407,8 @@ export class FeishuHarnessBridge {
|
|
|
3330
3407
|
content = enhanceContextContent(originalContent, snapshot, () => ({
|
|
3331
3408
|
channel: 'feishu',
|
|
3332
3409
|
senderId: senderOpenId(event),
|
|
3410
|
+
chatId: event.message.chat_id,
|
|
3411
|
+
threadId: event.message.thread_id,
|
|
3333
3412
|
}));
|
|
3334
3413
|
contextEnhanced = content !== originalContent;
|
|
3335
3414
|
}
|
|
@@ -3583,7 +3662,18 @@ export class FeishuHarnessBridge {
|
|
|
3583
3662
|
const question = pending.questions[pending.index];
|
|
3584
3663
|
if (!question) return;
|
|
3585
3664
|
|
|
3586
|
-
pending
|
|
3665
|
+
await this.#submitQuestionAnswer(pending, text, {
|
|
3666
|
+
chatId: event.message.chat_id,
|
|
3667
|
+
messageId,
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
async #submitQuestionAnswer(pending, answerText, { chatId, messageId } = {}) {
|
|
3672
|
+
const question = pending.questions[pending.index];
|
|
3673
|
+
if (!question) return;
|
|
3674
|
+
pending.chatId = chatId ?? pending.chatId;
|
|
3675
|
+
|
|
3676
|
+
pending.answers.push(harnessAnswerForQuestion(question, answerText));
|
|
3587
3677
|
pending.index += 1;
|
|
3588
3678
|
if (pending.index < pending.questions.length) {
|
|
3589
3679
|
if (pending.claimedReplyMessageId === messageId) {
|
|
@@ -3601,6 +3691,7 @@ export class FeishuHarnessBridge {
|
|
|
3601
3691
|
}
|
|
3602
3692
|
|
|
3603
3693
|
pending.submitting = true;
|
|
3694
|
+
const key = pending.key;
|
|
3604
3695
|
try {
|
|
3605
3696
|
await pending.interaction.respond({
|
|
3606
3697
|
ok: true,
|
|
@@ -3618,7 +3709,9 @@ export class FeishuHarnessBridge {
|
|
|
3618
3709
|
if (error?.code === 'interaction-not-pending') {
|
|
3619
3710
|
this.#rememberResolvedInteraction(key, pending);
|
|
3620
3711
|
this.#clearPendingInteraction(key, pending.interactionId);
|
|
3621
|
-
|
|
3712
|
+
if (chatId && messageId) {
|
|
3713
|
+
await this.#send(chatId, INTERACTION_RESOLVED_TEXT(), { replyTo: messageId }).catch(() => undefined);
|
|
3714
|
+
}
|
|
3622
3715
|
return;
|
|
3623
3716
|
}
|
|
3624
3717
|
pending.submitting = false;
|
|
@@ -3626,8 +3719,10 @@ export class FeishuHarnessBridge {
|
|
|
3626
3719
|
pending.index -= 1;
|
|
3627
3720
|
this.#status.lastError = '回答提交失败。';
|
|
3628
3721
|
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
|
|
3629
|
-
|
|
3630
|
-
|
|
3722
|
+
if (chatId) {
|
|
3723
|
+
await this.#send(chatId, t('回答提交失败,请重新发送当前问题的答案。'))
|
|
3724
|
+
.catch(() => undefined);
|
|
3725
|
+
}
|
|
3631
3726
|
}
|
|
3632
3727
|
}
|
|
3633
3728
|
|
|
@@ -3643,6 +3738,32 @@ export class FeishuHarnessBridge {
|
|
|
3643
3738
|
actor,
|
|
3644
3739
|
requiresMention,
|
|
3645
3740
|
send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
|
|
3741
|
+
// Approvals render as interactive cards with approve/reject buttons by
|
|
3742
|
+
// default. Set the bridge `interactionCards` option (or
|
|
3743
|
+
// DSH_IM_INTERACTION_CARDS=0) to keep the plain-text reply flow.
|
|
3744
|
+
...(this.#interactionCards
|
|
3745
|
+
? {
|
|
3746
|
+
render: async (pending) => {
|
|
3747
|
+
// Show the approval as an interactive card with approve/reject buttons.
|
|
3748
|
+
await this.#sendCard(
|
|
3749
|
+
chatId,
|
|
3750
|
+
approvalCard({
|
|
3751
|
+
toolName: pending.toolCall?.name ?? pending.payload?.toolName,
|
|
3752
|
+
operation: operationArguments(pending.toolCall),
|
|
3753
|
+
reason: pending.payload?.reason,
|
|
3754
|
+
approvalId: pending.approvalId,
|
|
3755
|
+
}),
|
|
3756
|
+
{ key, replyTo: replyToMessageId },
|
|
3757
|
+
).catch(async () => {
|
|
3758
|
+
// Fall back to the plain-text approval if the card cannot be
|
|
3759
|
+
// sent. If the text send also fails, let the error propagate so
|
|
3760
|
+
// the pending approval is not marked as presented and the
|
|
3761
|
+
// existing retry/reconnect logic can run.
|
|
3762
|
+
await this.#send(chatId, pending.text, { replyTo: replyToMessageId });
|
|
3763
|
+
});
|
|
3764
|
+
},
|
|
3765
|
+
}
|
|
3766
|
+
: {}),
|
|
3646
3767
|
})) return;
|
|
3647
3768
|
|
|
3648
3769
|
// Approval requests return above; the existing question state machine stays unchanged.
|
|
@@ -3736,18 +3857,53 @@ export class FeishuHarnessBridge {
|
|
|
3736
3857
|
async #presentInteraction(pending) {
|
|
3737
3858
|
const question = pending.questions[pending.index];
|
|
3738
3859
|
if (!question) return;
|
|
3739
|
-
const
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
//
|
|
3749
|
-
|
|
3750
|
-
|
|
3860
|
+
const options = Array.isArray(question?.options) ? question.options : [];
|
|
3861
|
+
// Single-choice questions with options render as interactive cards by
|
|
3862
|
+
// default. Multi-select or free-text questions and the text reply flow
|
|
3863
|
+
// remain when `interactionCards` is disabled (or DSH_IM_INTERACTION_CARDS=0).
|
|
3864
|
+
const interactive = this.#interactionCards
|
|
3865
|
+
&& options.length > 0
|
|
3866
|
+
&& question.multiSelect !== true;
|
|
3867
|
+
let messageId;
|
|
3868
|
+
if (interactive) {
|
|
3869
|
+
// Single-choice question with options: render each option as a button.
|
|
3870
|
+
messageId = await this.#sendCard(
|
|
3871
|
+
pending.chatId,
|
|
3872
|
+
questionCard({
|
|
3873
|
+
interactionId: pending.interactionId,
|
|
3874
|
+
header: question.header,
|
|
3875
|
+
question: question.question,
|
|
3876
|
+
detail: question.detail,
|
|
3877
|
+
options,
|
|
3878
|
+
index: pending.index,
|
|
3879
|
+
total: pending.questions.length,
|
|
3880
|
+
}),
|
|
3881
|
+
{ key: pending.key, replyTo: pending.replyToMessageId },
|
|
3882
|
+
).catch(async () => {
|
|
3883
|
+
// Fall back to the plain-text question if the card cannot be sent.
|
|
3884
|
+
// If the text send also fails, let the error propagate so the pending
|
|
3885
|
+
// question is not marked as presented and the existing retry logic runs.
|
|
3886
|
+
return this.#send(
|
|
3887
|
+
pending.chatId,
|
|
3888
|
+
harnessQuestionText(question, pending.index, pending.questions.length, {
|
|
3889
|
+
requiresMention: pending.requiresMention,
|
|
3890
|
+
}),
|
|
3891
|
+
{ replyTo: pending.replyToMessageId },
|
|
3892
|
+
);
|
|
3893
|
+
});
|
|
3894
|
+
} else {
|
|
3895
|
+
// Multi-select or free-text questions keep the plain-text reply flow.
|
|
3896
|
+
messageId = await this.#send(
|
|
3897
|
+
pending.chatId,
|
|
3898
|
+
harnessQuestionText(
|
|
3899
|
+
question,
|
|
3900
|
+
pending.index,
|
|
3901
|
+
pending.questions.length,
|
|
3902
|
+
{ requiresMention: pending.requiresMention },
|
|
3903
|
+
),
|
|
3904
|
+
{ replyTo: pending.replyToMessageId },
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3751
3907
|
if (messageId) {
|
|
3752
3908
|
pending.questionMessageIds.add(messageId);
|
|
3753
3909
|
if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
|
|
@@ -492,6 +492,7 @@ export function menuHelpText() {
|
|
|
492
492
|
'/session ID 绑定已有会话',
|
|
493
493
|
'/workspacelist 列出工作区',
|
|
494
494
|
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
495
|
+
'/ws、/wsl、/workspaces 工作区命令别名',
|
|
495
496
|
'/new 开启全新会话',
|
|
496
497
|
'',
|
|
497
498
|
'📊 状态 / 压缩',
|
|
@@ -595,6 +596,7 @@ export function helpCard(extraTextLines = []) {
|
|
|
595
596
|
{ tag: 'hr' },
|
|
596
597
|
{ tag: 'div', text: markdown([
|
|
597
598
|
t(HELP_TEXT_COMMANDS),
|
|
599
|
+
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
|
598
600
|
t('`/version` — 查看插件版本'),
|
|
599
601
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
600
602
|
].join('\n') + extraText) },
|
|
@@ -867,3 +869,70 @@ export function customSteerCard() {
|
|
|
867
869
|
];
|
|
868
870
|
return cardWith(t('➕ 自定义指令'), elements);
|
|
869
871
|
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Interactive approval card with approve / reject buttons. Action values carry
|
|
875
|
+
* the approvalId so the card callback can submit the decision:
|
|
876
|
+
* approve:<approvalId> / reject:<approvalId>
|
|
877
|
+
* `requiresMention` is advisory; a button click is itself the operator's
|
|
878
|
+
* explicit intent, so it does not need an @ mention in groups.
|
|
879
|
+
*/
|
|
880
|
+
export function approvalCard({ toolName, operation, reason, approvalId }) {
|
|
881
|
+
const elements = [];
|
|
882
|
+
if (toolName) {
|
|
883
|
+
elements.push({ tag: 'div', text: markdown(t('工具:{tool}', { tool: String(toolName) })) });
|
|
884
|
+
}
|
|
885
|
+
if (operation) {
|
|
886
|
+
// Cap the operation text so an oversized argument list cannot overflow the
|
|
887
|
+
// card (the plain-text path rejects >6000 chars; here we truncate so the
|
|
888
|
+
// approve/reject buttons still render).
|
|
889
|
+
const MAX_OPERATION_CHARS = 6_000;
|
|
890
|
+
const op = String(operation);
|
|
891
|
+
const shown = op.length > MAX_OPERATION_CHARS
|
|
892
|
+
? `${op.slice(0, MAX_OPERATION_CHARS)}\n…(操作参数过长,已截断)`
|
|
893
|
+
: op;
|
|
894
|
+
elements.push({ tag: 'div', text: markdown(t('操作参数:\n{operation}', { operation: shown })) });
|
|
895
|
+
}
|
|
896
|
+
if (reason) {
|
|
897
|
+
elements.push({ tag: 'div', text: markdown(t('原因:{reason}', { reason: String(reason) })) });
|
|
898
|
+
}
|
|
899
|
+
elements.push(
|
|
900
|
+
{ tag: 'hr' },
|
|
901
|
+
buttonPair(t('✅ 批准'), `approve:${approvalId}`, t('❌ 拒绝'), `reject:${approvalId}`),
|
|
902
|
+
);
|
|
903
|
+
return cardWith(t('🔐 工具审批'), elements);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Interactive question card. When the question carries options, each option is
|
|
908
|
+
* rendered as its own button; the selected option label is submitted via a
|
|
909
|
+
* card callback. Multi-select questions fall back to the plain-text flow (the
|
|
910
|
+
* caller decides), because a multi-select needs a confirm step.
|
|
911
|
+
* Action: answer:<interactionId>:<optionLabel>
|
|
912
|
+
*/
|
|
913
|
+
export function questionCard({ interactionId, header, question, detail, options, index, total }) {
|
|
914
|
+
const elements = [];
|
|
915
|
+
const progress = total > 1 ? `(${index + 1}/${total})` : '';
|
|
916
|
+
if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
|
|
917
|
+
const qText = typeof question === 'string' && question.trim() ? question : t('请输入你的回答。');
|
|
918
|
+
elements.push({ tag: 'div', text: markdown(String(qText)) });
|
|
919
|
+
if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
|
|
920
|
+
|
|
921
|
+
if (Array.isArray(options) && options.length > 0) {
|
|
922
|
+
elements.push({ tag: 'hr' });
|
|
923
|
+
for (const option of options) {
|
|
924
|
+
const label = typeof option?.label === 'string' ? option.label : '';
|
|
925
|
+
if (!label) continue;
|
|
926
|
+
const description = typeof option?.description === 'string' && option.description.trim()
|
|
927
|
+
? option.description.trim()
|
|
928
|
+
: '';
|
|
929
|
+
// Include the option description in the button so the user sees the full
|
|
930
|
+
// meaning (mirrors the text form "1. label — description").
|
|
931
|
+
const buttonText = description ? `${label}\n${description}` : label;
|
|
932
|
+
// Action carries the question index so a stale card from a previous
|
|
933
|
+
// question cannot be applied to the current one: answer:<interactionId>:<index>:<label>
|
|
934
|
+
elements.push(button(buttonText, `answer:${interactionId}:${index}:${label}`));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return cardWith(t('❓ 请补充信息{progress}', { progress }), elements);
|
|
938
|
+
}
|
|
@@ -288,6 +288,11 @@ export class FeishuRuntime {
|
|
|
288
288
|
groupResponseMode: this.#groupResponseMode,
|
|
289
289
|
repair: this.#repair,
|
|
290
290
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
291
|
+
// Interaction cards (approval/question buttons) are on by default.
|
|
292
|
+
// Set DSH_IM_INTERACTION_CARDS=0 to fall back to plain-text replies.
|
|
293
|
+
interactionCards: !['0', 'false', 'no', 'off'].includes(
|
|
294
|
+
String(process.env.DSH_IM_INTERACTION_CARDS ?? '').trim().toLowerCase(),
|
|
295
|
+
),
|
|
291
296
|
signal,
|
|
292
297
|
logger: this.#logger,
|
|
293
298
|
});
|
|
@@ -43,6 +43,9 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
|
|
|
43
43
|
{ command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
|
|
44
44
|
{ command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
|
|
45
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' },
|
|
46
49
|
{ command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
|
|
47
50
|
{ command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
|
|
48
51
|
{ command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
|
|
@@ -90,6 +90,7 @@ function helpText() {
|
|
|
90
90
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
91
91
|
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
|
92
92
|
t('/workspacelist 列出工作区绝对路径'),
|
|
93
|
+
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
|
93
94
|
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
|
94
95
|
t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
|
|
95
96
|
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
|
@@ -890,6 +891,7 @@ export class QqHarnessBridge {
|
|
|
890
891
|
channel: 'qq',
|
|
891
892
|
senderId: sender,
|
|
892
893
|
senderName: message.kind === 'group' ? message.senderName : undefined,
|
|
894
|
+
chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
|
|
893
895
|
}));
|
|
894
896
|
contextEnhanced = content !== originalContent;
|
|
895
897
|
}
|
|
@@ -1,6 +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
|
-
'channel', 'conversationType', 'senderId', 'senderName', 'conversationTitle',
|
|
3
|
+
'channel', 'conversationType', 'senderId', 'senderName', 'conversationTitle',
|
|
4
|
+
'chatId', 'threadId', 'botId',
|
|
4
5
|
]);
|
|
5
6
|
|
|
6
7
|
export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
|
|
@@ -38,7 +39,7 @@ const CHANNELS = new Set([
|
|
|
38
39
|
]);
|
|
39
40
|
const SOURCE_LIMITS = {
|
|
40
41
|
channel: 16, conversationType: 6, senderId: 256, senderName: 256,
|
|
41
|
-
conversationTitle: 256, botId: 128,
|
|
42
|
+
conversationTitle: 256, chatId: 256, threadId: 256, botId: 128,
|
|
42
43
|
};
|
|
43
44
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
|
|
44
45
|
|
|
@@ -64,7 +65,7 @@ function validateContextEnhancementScope(input) {
|
|
|
64
65
|
throw invalidConfig('群聊和私聊开关必须是布尔值。');
|
|
65
66
|
}
|
|
66
67
|
if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
|
|
67
|
-
throw invalidConfig('
|
|
68
|
+
throw invalidConfig('来源字段只能选择已定义的八个字段。');
|
|
68
69
|
}
|
|
69
70
|
if (typeof guidance !== 'string' || guidance.length > CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH) {
|
|
70
71
|
throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
|
|
@@ -150,7 +151,7 @@ function sourceString(value, field) {
|
|
|
150
151
|
function sourceBlock(snapshot, sourceFactory) {
|
|
151
152
|
const { fields } = snapshot.config;
|
|
152
153
|
const needsSource = fields.some((field) => [
|
|
153
|
-
'channel', 'senderId', 'senderName', 'conversationTitle',
|
|
154
|
+
'channel', 'senderId', 'senderName', 'conversationTitle', 'chatId', 'threadId',
|
|
154
155
|
].includes(field));
|
|
155
156
|
const source = needsSource ? sourceFactory?.() : null;
|
|
156
157
|
const projected = {};
|