@xmanrui/dsh-im 4.14.0 → 4.16.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.
Files changed (69) hide show
  1. package/README.en.md +10 -2
  2. package/README.md +17 -6
  3. package/lib/client.js +1980 -616
  4. package/lib/index.js +303 -293
  5. package/package.json +6 -2
  6. package/plugin-src/client/access-policy-settings.js +6 -0
  7. package/plugin-src/client/channels/feishu/index.js +16 -4
  8. package/plugin-src/client/channels/wecom-app/api.js +124 -0
  9. package/plugin-src/client/channels/wecom-app/index.js +612 -0
  10. package/plugin-src/client/channels/wecom-app/styles.js +25 -0
  11. package/plugin-src/client/delivery-settings.js +7 -0
  12. package/plugin-src/client/i18n.js +64 -1
  13. package/plugin-src/client/index.js +21 -0
  14. package/plugin-src/client/model-setting.js +135 -69
  15. package/plugin-src/client/session-channel-logos.js +241 -0
  16. package/plugin-src/client/styles.js +22 -7
  17. package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
  18. package/plugin-src/host/channels/discord/index.mjs +8 -10
  19. package/plugin-src/host/channels/feishu/index.mjs +8 -15
  20. package/plugin-src/host/channels/office/index.mjs +9 -5
  21. package/plugin-src/host/channels/qq/index.mjs +8 -15
  22. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  23. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
  24. package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
  25. package/plugin-src/host/channels/shared/startup.mjs +47 -0
  26. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  27. package/plugin-src/host/channels/slack/index.mjs +8 -10
  28. package/plugin-src/host/channels/telegram/index.mjs +8 -10
  29. package/plugin-src/host/channels/wecom/index.mjs +8 -15
  30. package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
  31. package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
  32. package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
  33. package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
  34. package/plugin-src/host/channels/weixin/index.mjs +8 -15
  35. package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
  36. package/plugin-src/host/delivery-adapter.mjs +4 -0
  37. package/plugin-src/host/delivery-suggestions.mjs +4 -0
  38. package/plugin-src/host/index.mjs +21 -2
  39. package/plugin-src/host/session-title-prefix.mjs +122 -0
  40. package/scripts/verify-model-setting.mjs +54 -0
  41. package/scripts/verify-session-channel-logos.mjs +59 -0
  42. package/scripts/verify-session-title-prefix.mjs +149 -0
  43. package/src/channels/feishu/bridge.mjs +305 -12
  44. package/src/channels/feishu/feishu-cards.mjs +38 -0
  45. package/src/channels/feishu/feishu-channel.mjs +88 -20
  46. package/src/channels/feishu/plugin-controller.mjs +1 -0
  47. package/src/channels/feishu/repair-manager.mjs +3 -2
  48. package/src/channels/shared/bot-workspace-store.mjs +11 -3
  49. package/src/channels/shared/command-catalog.mjs +102 -0
  50. package/src/channels/shared/i18n-en/feishu.mjs +14 -2
  51. package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
  52. package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
  53. package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
  54. package/src/channels/shared/i18n-en.mjs +2 -0
  55. package/src/channels/shared/model-setting.mjs +43 -8
  56. package/src/channels/shared/session-channel-labels.mjs +26 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +2 -26
  58. package/src/channels/telegram/telegram-api.mjs +18 -6
  59. package/src/channels/telegram/telegram-runtime.mjs +34 -32
  60. package/src/channels/wecom/send-error.mjs +31 -0
  61. package/src/channels/wecom/wecom-bridge.mjs +59 -17
  62. package/src/channels/wecom-app/callback-server.mjs +471 -0
  63. package/src/channels/wecom-app/config-store.mjs +240 -0
  64. package/src/channels/wecom-app/harness-client.mjs +11 -0
  65. package/src/channels/wecom-app/state-store.mjs +107 -0
  66. package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
  67. package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
  68. package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
  69. package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
@@ -207,59 +207,120 @@ export class VerifiedFeishuChannel {
207
207
  const cards = [];
208
208
  let activeCard = null;
209
209
  let rotating = false;
210
+ let awaitingPresentation = false;
211
+ // issue #163:过程写卡与换卡定格共用一条写队列串行化——在途写必然先于
212
+ // 定格完成,消除「写飞越定格」竞态;前序失败不阻塞后续写入。
213
+ let writeQueue = Promise.resolve();
214
+ const enqueue = (job) => {
215
+ const run = writeQueue.then(job, job);
216
+ writeQueue = run.catch(() => undefined);
217
+ return run;
218
+ };
210
219
  try {
211
220
  activeCard = await this.#createStreamCard(chatId, options);
212
221
  cards.push(activeCard);
213
222
  let lastContent = this.#initialText;
223
+ let lastContentIsTransient = true;
224
+ // Track the current card separately from incoming snapshots: held or
225
+ // failed writes have not delivered any text that can be deduplicated.
226
+ let activeView = lastContent;
227
+ let activeTextPrefix = null;
214
228
  // issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
215
229
  // 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态;
216
230
  // 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于
217
231
  // 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
232
+ // issue #163:定格后进入换卡挂起(awaitingPresentation),挂起期内
233
+ // setContent 只推进快照、不建卡不写卡;bridge 呈现交互消息后调用
234
+ // interactionPresented() 解除挂起,此后建的新卡必然位于交互消息下方。
218
235
  const ensureActiveCard = async () => {
219
236
  if (!rotating) return activeCard;
220
237
  activeCard = await this.#createStreamCard(chatId, options);
238
+ activeView = this.#initialText;
239
+ activeTextPrefix = null;
221
240
  cards.push(activeCard);
222
241
  rotating = false;
223
242
  return activeCard;
224
243
  };
244
+ // issue #163:冻结前缀去重——换卡定格时旧卡已展示 frozenContent;此后
245
+ // 新卡只展示剥离该前缀后的增量(重放快照剥为空白则跳过写卡),终稿
246
+ // 分段同样使用增量视图,提问前的过程不再重复播放。
247
+ let frozenContent = null;
248
+ const applyFrozenDedup = (next) => {
249
+ if (frozenContent === null || !next.startsWith(frozenContent)) return next;
250
+ const rest = next.slice(frozenContent.length);
251
+ return rest.trim().length > 0 ? rest : ''; // 不改写增量原文;全空白视为无增量
252
+ };
253
+ // 评审补充(#163):去重基线与定格内容必须以「旧卡实际展示的内容」为准——
254
+ // 超长快照的旧卡只展示了截断前缀,若冻结完整 lastContent,未展示的尾部
255
+ // 会被整段剥掉而丢失;再次换卡时旧卡展示的是上一轮增量,不得回写完整快照。
256
+ const shownPrefixOf = (text) => {
257
+ const notice = `\n\n${t('内容较长,生成完成后将分段发送完整回答。')}`;
258
+ return text.length <= MAX_STREAM_CHARS
259
+ ? text
260
+ : streamTextPrefix(text, MAX_STREAM_CHARS - notice.length);
261
+ };
225
262
  const controller = {
226
263
  get messageId() {
227
264
  return activeCard.messageId;
228
265
  },
229
- rotate: async () => {
266
+ interactionPresented: () => {
267
+ awaitingPresentation = false;
268
+ },
269
+ rotate: () => enqueue(async () => {
230
270
  if (rotating) return;
231
271
  rotating = true;
272
+ awaitingPresentation = true;
273
+ const shownPrefix = shownPrefixOf(activeView);
232
274
  try {
233
275
  await this.#updateStreamCard(
234
276
  activeCard,
235
- `${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`,
277
+ `${streamPreview(shownPrefix)}\n\n${t('⤵️ 最终结果见下方')}`,
236
278
  );
279
+ // Tool/status messages remain visible but never extend the text
280
+ // prefix. Only a successfully displayed text snapshot advances it.
281
+ if (activeTextPrefix !== null) frozenContent = activeTextPrefix;
237
282
  await this.#finishStreamCard(activeCard);
238
283
  } catch (error) {
239
284
  // 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。
240
285
  console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message);
241
286
  }
242
- },
243
- setContent: async (content) => {
287
+ }),
288
+ setContent: (content, { transient = false } = {}) => enqueue(async () => {
244
289
  const next = String(content ?? '') || '…';
245
- const card = await ensureActiveCard();
246
- await this.#updateStreamCard(card, streamPreview(next));
247
- // Updates are replaceable snapshots, including progress/tool text.
248
- // Retain the full latest snapshot even when its preview is unchanged.
290
+ // Retain the latest snapshot even if it is a replay or a held write.
249
291
  lastContent = next;
250
- },
292
+ lastContentIsTransient = transient;
293
+ const visible = transient ? next : applyFrozenDedup(next);
294
+ if (visible === '') return; // 重放快照不建卡不写卡
295
+ if (awaitingPresentation) return; // 换卡挂起:视图已推进,仅挂起卡片写入
296
+ const card = await ensureActiveCard();
297
+ await this.#updateStreamCard(card, streamPreview(visible));
298
+ activeView = visible;
299
+ const previousPrefix = frozenContent !== null && next.startsWith(frozenContent)
300
+ ? frozenContent
301
+ : '';
302
+ activeTextPrefix = transient ? null : previousPrefix + shownPrefixOf(visible);
303
+ }),
251
304
  };
252
305
 
253
306
  await input.markdown(controller);
254
- const chunks = splitStreamContent(lastContent);
255
- for (const [index, chunk] of chunks.entries()) {
256
- const card = index === 0
257
- ? await ensureActiveCard()
258
- : await this.#createStreamCard(chatId, options);
259
- if (index > 0) cards.push(card);
260
- await this.#updateStreamCard(card, chunk);
261
- await this.#finishStreamCard(card);
262
- }
307
+ await enqueue(async () => {
308
+ // 终稿强制解除挂起:异常路径下呈现通知缺失时仍可收尾。
309
+ awaitingPresentation = false;
310
+ // Recompute from the final snapshot, including an empty delta, rather
311
+ // than retaining the last nonempty progress/tool view as the answer.
312
+ const finalView = (lastContentIsTransient ? lastContent : applyFrozenDedup(lastContent))
313
+ || lastContent;
314
+ const chunks = splitStreamContent(finalView);
315
+ for (const [index, chunk] of chunks.entries()) {
316
+ const card = index === 0
317
+ ? await ensureActiveCard()
318
+ : await this.#createStreamCard(chatId, options);
319
+ if (index > 0) cards.push(card);
320
+ await this.#updateStreamCard(card, chunk);
321
+ await this.#finishStreamCard(card);
322
+ }
323
+ });
263
324
  return {
264
325
  messageId: cards[0].messageId,
265
326
  providerMessageIds: cards.map((card) => card.messageId),
@@ -507,14 +568,16 @@ export class VerifiedFeishuChannel {
507
568
  return messageId;
508
569
  }
509
570
 
510
- async #recall(messageId) {
571
+ async #recall(messageId, label = 'streaming card') {
511
572
  try {
512
573
  const response = await this.#client.im.v1.message.delete({
513
574
  path: { message_id: messageId },
514
575
  });
515
576
  assertApiSuccess('Feishu message delete', response);
577
+ return true;
516
578
  } catch (error) {
517
- console.warn('[bridge] unable to recall a failed streaming card:', error.message);
579
+ console.warn(`[bridge] unable to recall ${label}:`, error.message);
580
+ return false;
518
581
  }
519
582
  }
520
583
 
@@ -528,6 +591,11 @@ export class VerifiedFeishuChannel {
528
591
  return reactionId;
529
592
  }
530
593
 
594
+ /** Delete one previously sent message (step-push heartbeat cleanup). */
595
+ async recallMessage(messageId) {
596
+ return this.#recall(messageId, 'thinking status heartbeat');
597
+ }
598
+
531
599
  async removeReaction(messageId, reactionId) {
532
600
  assertApiSuccess('Feishu reaction.delete', await this.#client.im.v1.messageReaction.delete({
533
601
  path: { message_id: messageId, reaction_id: reactionId },
@@ -6,6 +6,7 @@ export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
6
6
  export const REQUIRED_TENANT_SCOPES = Object.freeze([
7
7
  'im:message.p2p_msg:readonly',
8
8
  'im:message.group_at_msg:readonly',
9
+ 'im:message.group_at_msg.include_bot:readonly',
9
10
  'im:message:readonly',
10
11
  'im:message:send_as_bot',
11
12
  'im:message.reactions:write_only',
@@ -76,8 +76,8 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
76
76
  * shares RegistrationManager's polling/state implementation while fixing the
77
77
  * update manifest in one place so callers can add only the card callback, the
78
78
  * message-read scope needed to download user-sent media, the resource scope
79
- * needed to upload bot-sent images/files, and the Slash Command scopes needed
80
- * for the native command panel, without adding unrelated scopes, events,
79
+ * needed to upload bot-sent images/files, the group bot-mention scope, and the
80
+ * Slash Command scopes for the native command panel, without adding unrelated scopes, events,
81
81
  * presets, or createOnly.
82
82
  */
83
83
  export class CallbackRepairManager {
@@ -116,6 +116,7 @@ export class CallbackRepairManager {
116
116
  tenant: [
117
117
  FEISHU_MESSAGE_READ_SCOPE,
118
118
  FEISHU_RESOURCE_SCOPE,
119
+ 'im:message.group_at_msg.include_bot:readonly',
119
120
  ...SLASH_COMMAND_TENANT_SCOPES,
120
121
  ],
121
122
  },
@@ -24,7 +24,8 @@ import {
24
24
  validateContextEnhancementConfig,
25
25
  } from './context-enhancement.mjs';
26
26
  import {
27
- modelCatalogHas,
27
+ confirmsModelSelection,
28
+ modelCatalogEntry,
28
29
  normalizeModelCatalog,
29
30
  sameModelSelection,
30
31
  validateModelSelection,
@@ -1372,7 +1373,7 @@ export function createBotWorkspaceScope(
1372
1373
  model,
1373
1374
  options.signal ? { signal: options.signal } : {},
1374
1375
  );
1375
- if (!sameModelSelection(selected?.selected, model)) {
1376
+ if (!confirmsModelSelection(selected?.selected, model)) {
1376
1377
  const error = new Error('Harness did not confirm the selected model');
1377
1378
  error.code = 'model-selection-mismatch';
1378
1379
  throw error;
@@ -1625,9 +1626,16 @@ export function createWorkspaceAwareController(controller, {
1625
1626
  const catalog = model && modelCatalog
1626
1627
  ? await resolveModelCatalog(modelCatalog)
1627
1628
  : null;
1628
- if (model && (!modelCatalog || !modelCatalogHas(catalog, model))) {
1629
+ const entry = modelCatalogEntry(catalog, model);
1630
+ if (model && (!modelCatalog || !entry)) {
1629
1631
  throw unavailableModel();
1630
1632
  }
1633
+ if (model?.reasoningEffort !== undefined
1634
+ && !entry.reasoning?.efforts.some((effort) => effort.id === model.reasoningEffort)) {
1635
+ const error = new Error('当前模型不支持所选思考强度,请重新选择。');
1636
+ error.code = 'model-reasoning-unavailable';
1637
+ throw error;
1638
+ }
1631
1639
  await workspaces.setModel(botId, model, { incarnation });
1632
1640
  return decorateResult(
1633
1641
  workspaces,
@@ -0,0 +1,102 @@
1
+ import { t } from './i18n.mjs';
2
+
3
+ const TEXT_CHANNELS = Object.freeze(['telegram', 'slack', 'discord', 'whatsapp']);
4
+
5
+ function defineCatalogCommand(name, description, help, { aliases = [], ...options } = {}) {
6
+ return Object.freeze({
7
+ name,
8
+ description,
9
+ channels: TEXT_CHANNELS,
10
+ menuVisible: true,
11
+ ...options,
12
+ aliases: Object.freeze(aliases.map((alias) => Object.freeze({
13
+ menuVisible: true,
14
+ ...(typeof alias === 'string' ? { name: alias } : alias),
15
+ }))),
16
+ help: Object.freeze(help),
17
+ });
18
+ }
19
+
20
+ // Presentation metadata for TextHarnessBridge commands, in help order.
21
+ // Keep each command's aliases, short description and full usage together here.
22
+ // Handlers still own parsing and authorization; privateOnly is descriptive.
23
+ // enabled: false removes a definition from both consumers; menuVisible: false
24
+ // hides a command (or alias) only from the native menu, leaving help available.
25
+ export const SHARED_COMMAND_CATALOG = Object.freeze([
26
+ defineCatalogCommand('new', '开启一个全新会话', [
27
+ '/new 开启一个全新会话',
28
+ ]),
29
+ defineCatalogCommand('compact', '压缩当前会话的较早上下文', [
30
+ '/compact 压缩当前会话的较早上下文',
31
+ ]),
32
+ defineCatalogCommand('history', '查看最近历史消息(仅私聊)', [
33
+ '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
34
+ ], { privateOnly: true }),
35
+ defineCatalogCommand('workspace', '切换工作区', [
36
+ '/workspace 工作区序号或绝对路径 切换工作区',
37
+ ], { aliases: ['ws'] }),
38
+ defineCatalogCommand('workspacelist', '列出工作区绝对路径', [
39
+ '/workspacelist 列出工作区绝对路径',
40
+ '/ws、/wsl、/workspaces 工作区命令别名',
41
+ ], { aliases: ['workspaces', 'wsl'] }),
42
+ defineCatalogCommand('sessionlist', '列出会话 ID 和标题', [
43
+ '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
44
+ '/sessionlist --limit N 仅列出当前工作区前 N 个会话',
45
+ ], { aliases: ['sessions'] }),
46
+ defineCatalogCommand('session', '将当前聊天绑定到指定会话', [
47
+ '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
48
+ ]),
49
+ defineCatalogCommand('models', '按序号列出所有可用模型', [
50
+ '/models 按序号列出所有可用模型',
51
+ ]),
52
+ defineCatalogCommand('reasoninglist', '按序号列出当前模型可用推理等级', [
53
+ '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
54
+ ], { aliases: ['reasonings'] }),
55
+ defineCatalogCommand('reasoning', '查看或切换当前推理等级', [
56
+ '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
57
+ ]),
58
+ defineCatalogCommand('model', '查看或切换当前会话模型', [
59
+ '/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
60
+ '示例:先发 /models,再发 /model 2 [推理等级ID]',
61
+ ]),
62
+ defineCatalogCommand('presetlist', '列出可用 Agent Preset', [
63
+ '/presetlist 或 /presets 按序号列出可用 Agent Preset',
64
+ ], { aliases: ['presets'] }),
65
+ defineCatalogCommand('preset', '查看或设置新会话 Agent Preset', [
66
+ '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
67
+ '纯数字 ID:/preset id:<ID>',
68
+ '/preset --default 跟随 Host 默认',
69
+ ]),
70
+ defineCatalogCommand('stop', '停止当前任务', [
71
+ '/stop 停止当前任务',
72
+ ]),
73
+ defineCatalogCommand('steer', '纠偏当前任务', [
74
+ '/steer 补充指令 纠偏当前任务',
75
+ ]),
76
+ defineCatalogCommand('batch', '开始批量输入(仅私聊)', [
77
+ '/batch 开始批量输入(仅私聊,最多 10 条文字)',
78
+ ], { privateOnly: true }),
79
+ defineCatalogCommand('send', '提交当前批次', [
80
+ '/send 提交当前批次',
81
+ ], { privateOnly: true }),
82
+ defineCatalogCommand('cancel', '取消当前批次', [
83
+ '/cancel 取消当前批次',
84
+ ], { privateOnly: true }),
85
+ defineCatalogCommand('status', '检查连接状态', [
86
+ '/status 检查连接状态',
87
+ ]),
88
+ defineCatalogCommand('version', '查看插件版本', [
89
+ '/version 查看插件版本',
90
+ ]),
91
+ defineCatalogCommand('help', '显示帮助', [
92
+ '/help 显示本帮助',
93
+ ]),
94
+ ]);
95
+
96
+ export function commandsForChannel(channel, catalog = SHARED_COMMAND_CATALOG) {
97
+ return catalog.filter((item) => item.enabled !== false && item.channels.includes(channel));
98
+ }
99
+
100
+ export function commandHelpLines(channel, catalog = SHARED_COMMAND_CATALOG) {
101
+ return commandsForChannel(channel, catalog).flatMap((item) => item.help.map((line) => t(line)));
102
+ }
@@ -72,8 +72,8 @@ export default {
72
72
  '旧授权链接已作废,已生成新的修复链接。':
73
73
  'The previous authorization link was invalidated and a new repair link was generated.',
74
74
  '🔧 准备补全权限与回调。': '🔧 Preparing to complete permissions and the callback.',
75
- '本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。':
76
- 'This incrementally adds the currently missing items: the card callback card.action.trigger; the tenant scope im:message:readonly, shown by Feishu as “Read direct and group messages” and used to read images or files in user messages; im:resource, used to upload images or files sent by the bot; and application:app_slash_command:read / write for the native command panel. The confirmation page shows only items the app is currently missing; cancel if anything outside this scope appears.',
75
+ '本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);im:message.group_at_msg.include_bot:readonly(用于接收群内其他机器人 @ 当前机器人的消息);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。':
76
+ 'This incrementally adds the currently missing items: the card callback card.action.trigger; the tenant scope im:message:readonly, shown by Feishu as “Read direct and group messages” and used to read images or files in user messages; im:resource, used to upload images or files sent by the bot; im:message.group_at_msg.include_bot:readonly, used to receive group messages from other bots that mention this bot; and application:app_slash_command:read / write for the native command panel. The confirmation page shows only items the app is currently missing; cancel if anything outside this scope appears.',
77
77
  '当前设备直接打开:': 'Open directly on this device:',
78
78
  '若要用另一台设备扫码,发送 /repair qr。{expiry}。':
79
79
  'To scan with another device, send /repair qr. {expiry}.',
@@ -375,4 +375,16 @@ export default {
375
375
  '任务失败': 'Failed',
376
376
  // feishu/bridge.mjs — step push context injection
377
377
  '📎 已注入会话上下文': '📎 Context injected',
378
+ // feishu/feishu-cards.mjs — question card answered state + custom entry (issue #162)
379
+ '✏️ 其他答案…': '✏️ Other answer…',
380
+ '✅ 已回答{progress}': '✅ Answered{progress}',
381
+ '✅ 已选择:{label}': '✅ Selected: {label}',
382
+ '回答已提交,对话将继续。': 'Answer submitted. The conversation will continue.',
383
+ // feishu/bridge.mjs — answered interaction notices + custom answer guidance (issue #162)
384
+ '这个问题已经回答过了。': 'This question has already been answered.',
385
+ '想自定义答案?直接发送文字消息即可,将作为本题答案提交。':
386
+ 'Want a custom answer? Just send a text message and it will be submitted as your answer.',
387
+ // feishu/bridge.mjs — thinking status heartbeat
388
+ '⏳ 正在思考中…(已运行 {elapsed})': '⏳ Thinking… (elapsed {elapsed})',
389
+ '⏳ 正在思考中…': '⏳ Thinking…',
378
390
  };
@@ -118,6 +118,9 @@ export default {
118
118
  '直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。':
119
119
  'Send text, an image, a file, or a voice message already transcribed to text to continue the current session.',
120
120
  '{label}机器人已连接 DeepSeek Harness。': 'The {label} bot is connected to DeepSeek Harness.',
121
+ '查看最近历史消息(仅私聊)': 'Show recent history (private chats only)',
122
+ '按序号列出当前模型可用推理等级': 'List reasoning efforts for the current model by index',
123
+ '查看或切换当前推理等级': 'Show or switch the current reasoning effort',
121
124
  '/new 开启一个全新会话': '/new Start a brand-new session',
122
125
  '/compact 压缩当前会话的较早上下文': '/compact Compact the earlier context of the current session',
123
126
  '/workspace 工作区序号或绝对路径 切换工作区':
@@ -1,5 +1,14 @@
1
1
  // English translations (shared-c area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ '微信': 'Weixin',
4
+ '飞书': 'Feishu',
5
+ '钉钉': 'DingTalk',
6
+ '企业微信': 'Enterprise WeChat',
7
+ '{channel}正在初始化,请稍后重新读取。': '{channel} is initializing. Please reload shortly.',
8
+ '{channel}配置格式错误。请检查其数据目录中的 config.json 和 workspaces.json,修复后重启 DSH。详细原因请查看启动日志。': 'The {channel} configuration is invalid. Check config.json and workspaces.json in its data directory, then restart DSH. See the startup log for details.',
9
+ '{channel}配置格式错误。请检查其数据目录中的 config.json,修复后重启 DSH。详细原因请查看启动日志。': 'The {channel} configuration is invalid. Check config.json in its data directory, then restart DSH. See the startup log for details.',
10
+ '无法读取或写入{channel}配置。请检查其数据目录的访问权限,修复后重启 DSH。详细原因请查看启动日志。': 'The {channel} configuration could not be read or written. Check access to its data directory, then restart DSH. See the startup log for details.',
11
+ '{channel}初始化失败。请查看 DSH 启动日志中 failed to activate {id} 后的错误,修复后重启 DSH。': '{channel} initialization failed. Check the error after "failed to activate {id}" in the DSH startup log, fix it, then restart DSH.',
3
12
  // history-command.mjs / command help
4
13
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)':
5
14
  '/history [count] Preview recent messages (default 3, maximum 5)',
@@ -0,0 +1,33 @@
1
+ // English translations (wecom-app area). Keys are exact Chinese literals passed to t().
2
+ export default {
3
+ '企业微信应用': 'WeCom app',
4
+ '企业微信应用凭据缺失,请重新绑定。': 'WeCom app credentials are missing; please bind the app again.',
5
+ '企业微信应用凭据不完整,请重新绑定。': 'WeCom app credentials are incomplete; please bind the app again.',
6
+ '企业微信应用连接未就绪,插件会自动重试。': 'The WeCom app connection is not ready; the plugin will retry automatically.',
7
+ '企业微信应用连接未就绪,插件会自动重试': 'The WeCom app connection is not ready; the plugin will retry automatically',
8
+ '企业微信应用已绑定,但尚未就绪。': 'The WeCom app is bound but not ready yet.',
9
+ '企业微信应用已更新,但尚未就绪。': 'The WeCom app settings were saved, but the connection is not ready yet.',
10
+ '企业微信应用仍未就绪,请稍后重试。': 'The WeCom app is still not ready; please try again later.',
11
+ '企业微信应用当前离线': 'The WeCom app is currently offline',
12
+ '企业微信应用回调通道就绪': 'The WeCom app callback channel is ready',
13
+ '企业微信应用({corpId})': 'WeCom app ({corpId})',
14
+ '请输入正确的企业 ID(例如 ww1234567890abcdef)。': 'Enter a valid corp ID (for example ww1234567890abcdef).',
15
+ '请输入正确的应用 AgentId(纯数字)。': 'Enter a valid AgentId (digits only).',
16
+ '应用 Secret 格式不正确。': 'The app Secret format is invalid.',
17
+ '回调 Token 格式不正确。': 'The callback Token format is invalid.',
18
+ 'EncodingAESKey 必须是 43 位字母或数字。': 'EncodingAESKey must be 43 alphanumeric characters.',
19
+ '代理地址必须是 http(s) 地址': 'The API proxy address must be an http(s) URL',
20
+ '代理地址必须是 http(s) 地址。': 'The API proxy address must be an http(s) URL.',
21
+ '回调基址必须是 http(s) 地址': 'The callback base URL must be an http(s) URL',
22
+ '回调基址必须是 http(s) 地址。': 'The callback base URL must be an http(s) URL.',
23
+ '回调密钥已重置,但机器人重启失败,请手动重连。': 'The callback secret was reset, but the bot failed to restart; please reconnect manually.',
24
+ '企业微信应用交互问题发送失败。': 'Failed to send the WeCom app interaction question.',
25
+ '企业微信应用目前支持文字和图片消息。': 'The WeCom app currently supports text and image messages.',
26
+ '企业微信应用已连接 DeepSeek Harness,可在微信中直接使用。': 'The WeCom app is connected to DeepSeek Harness and works directly inside WeChat.',
27
+ '企业微信应用已连接 DeepSeek Harness。直接发送文字或图片即可开始,发送 /help 查看可用命令。': 'The WeCom app is connected to DeepSeek Harness. Send text or images to start, or send /help for available commands.',
28
+ '企业微信应用与 DeepSeek Harness 连接正常。': 'The WeCom app and DeepSeek Harness are connected.',
29
+ '结果文件「{name}」已生成,但企业微信应用当前没有文件消息发送权限,请检查应用的文件发送能力。': 'The result file "{name}" was generated, but the WeCom app currently lacks file-message permission; check the app file capability.',
30
+ '结果文件「{name}」超过企业微信可发送的文件大小,未发送。': 'The result file "{name}" exceeds the WeCom file size limit and was not sent.',
31
+ '结果文件「{name}」已生成,但企业微信拒绝了该文件消息。': 'The result file "{name}" was generated, but WeCom rejected the file message.',
32
+ '结果文件「{name}」发送失败,请稍后重试。': 'The result file "{name}" failed to send; please try again later.',
33
+ };
@@ -9,6 +9,7 @@ import sharedC from './i18n-en/shared-c.mjs';
9
9
  import feishu from './i18n-en/feishu.mjs';
10
10
  import dingtalk from './i18n-en/dingtalk.mjs';
11
11
  import wecom from './i18n-en/wecom.mjs';
12
+ import wecomApp from './i18n-en/wecom-app.mjs';
12
13
  import qq from './i18n-en/qq.mjs';
13
14
  import weixin from './i18n-en/weixin.mjs';
14
15
  import slack from './i18n-en/slack.mjs';
@@ -25,6 +26,7 @@ export const EN = Object.freeze(Object.assign(
25
26
  feishu,
26
27
  dingtalk,
27
28
  wecom,
29
+ wecomApp,
28
30
  qq,
29
31
  weixin,
30
32
  slack,
@@ -1,5 +1,6 @@
1
1
  const MAX_PROVIDER_ID_LENGTH = 256;
2
2
  const MAX_MODEL_ID_LENGTH = 1_024;
3
+ const MAX_EFFORT_ID_LENGTH = 256;
3
4
  const MAX_LABEL_LENGTH = 256;
4
5
  const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
5
6
  const UNSAFE_ID_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
@@ -27,7 +28,10 @@ export function normalizeModelSelection(value) {
27
28
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
28
29
  const provider = cleanId(value.provider, MAX_PROVIDER_ID_LENGTH);
29
30
  const model = cleanId(value.model, MAX_MODEL_ID_LENGTH);
30
- return provider && model ? { provider, model } : null;
31
+ const reasoningEffort = value.reasoningEffort === undefined
32
+ ? undefined : cleanId(value.reasoningEffort, MAX_EFFORT_ID_LENGTH);
33
+ if (!provider || !model || reasoningEffort === '') return null;
34
+ return { provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) };
31
35
  }
32
36
 
33
37
  export function validateModelSelection(value) {
@@ -42,7 +46,31 @@ export function validateModelSelection(value) {
42
46
  }
43
47
 
44
48
  export function sameModelSelection(left, right) {
45
- return left?.provider === right?.provider && left?.model === right?.model;
49
+ return left?.provider === right?.provider && left?.model === right?.model
50
+ && left?.reasoningEffort === right?.reasoningEffort;
51
+ }
52
+
53
+ // Harness may resolve an omitted effort to the model's default.
54
+ export function confirmsModelSelection(actual, requested) {
55
+ return actual?.provider === requested?.provider && actual?.model === requested?.model
56
+ && (requested?.reasoningEffort === undefined
57
+ || actual?.reasoningEffort === requested.reasoningEffort);
58
+ }
59
+
60
+ function normalizeReasoning(value) {
61
+ if (!value || typeof value !== 'object' || !Array.isArray(value.efforts)) return undefined;
62
+ const efforts = [];
63
+ const seen = new Set();
64
+ for (const entry of value.efforts) {
65
+ const id = cleanId(entry?.id, MAX_EFFORT_ID_LENGTH);
66
+ if (!id || seen.has(id)) continue;
67
+ seen.add(id);
68
+ const description = cleanLabel(entry.description, '');
69
+ efforts.push({ id, name: cleanLabel(entry.name, id), ...(description ? { description } : {}) });
70
+ }
71
+ if (!efforts.length) return undefined;
72
+ const defaultEffort = cleanId(value.defaultEffort, MAX_EFFORT_ID_LENGTH);
73
+ return { efforts, ...(seen.has(defaultEffort) ? { defaultEffort } : {}) };
46
74
  }
47
75
 
48
76
  export function modelSelectionId(value) {
@@ -68,9 +96,13 @@ export function normalizeModelCatalog(value) {
68
96
  const key = `${provider}\u0000${model}`;
69
97
  if (seen.has(key)) continue;
70
98
  seen.add(key);
99
+ const description = cleanLabel(entry.description, '');
100
+ const reasoning = normalizeReasoning(entry.reasoning);
71
101
  models.push({
72
102
  id: model,
73
103
  name: cleanLabel(entry.name, model),
104
+ ...(description ? { description } : {}),
105
+ ...(reasoning ? { reasoning } : {}),
74
106
  });
75
107
  }
76
108
  if (models.length > 0) {
@@ -91,13 +123,16 @@ export function normalizeModelCatalog(value) {
91
123
  return { groups, failures };
92
124
  }
93
125
 
94
- export function modelCatalogHas(catalog, selection) {
126
+ export function modelCatalogEntry(catalog, selection) {
95
127
  const normalized = normalizeModelSelection(selection);
96
- if (!normalized) return false;
97
- return normalizeModelCatalog(catalog).groups.some(
98
- (group) => group.id === normalized.provider
99
- && group.models.some((model) => model.id === normalized.model),
100
- );
128
+ if (!normalized) return undefined;
129
+ return normalizeModelCatalog(catalog).groups
130
+ .filter((group) => group.id === normalized.provider)
131
+ .flatMap((group) => group.models).find((model) => model.id === normalized.model);
132
+ }
133
+
134
+ export function modelCatalogHas(catalog, selection) {
135
+ return modelCatalogEntry(catalog, selection) !== undefined;
101
136
  }
102
137
 
103
138
  export async function listModelCatalog(harness, options = {}) {
@@ -0,0 +1,26 @@
1
+ export const SESSION_CHANNEL_LABELS = Object.freeze(Object.fromEntries(Object.entries({
2
+ weixin: ['微信', 'WeChat'],
3
+ feishu: ['飞书', 'Feishu'],
4
+ dingtalk: ['钉钉', 'DingTalk'],
5
+ wecom: ['企业微信', 'WeCom'],
6
+ qq: ['QQ', 'QQ'],
7
+ slack: ['Slack', 'Slack'],
8
+ telegram: ['Telegram', 'Telegram'],
9
+ discord: ['Discord', 'Discord'],
10
+ whatsapp: ['WhatsApp', 'WhatsApp'],
11
+ office: ['AI Office', 'AI Office'],
12
+ }).map(([channel, labels]) => [channel, Object.freeze(labels)])));
13
+
14
+ /** Read only the exact, leading title prefix emitted by dsh-im. */
15
+ export function parseSessionChannelTitle(title) {
16
+ if (typeof title !== 'string') return null;
17
+ for (const [channel, labels] of Object.entries(SESSION_CHANNEL_LABELS)) {
18
+ for (const label of labels) {
19
+ const prefix = `${label} · `;
20
+ if (title.startsWith(prefix) && title.slice(prefix.length).trim()) {
21
+ return { channel, title: title.slice(prefix.length) };
22
+ }
23
+ }
24
+ }
25
+ return null;
26
+ }
@@ -1,5 +1,6 @@
1
1
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from './deferred-delivery-coordinator.mjs';
2
2
  import { t } from './i18n.mjs';
3
+ import { commandHelpLines } from './command-catalog.mjs';
3
4
  import {
4
5
  COMMAND_PERMISSION_DENIED_MESSAGE,
5
6
  evaluateInboundAccess,
@@ -605,32 +606,7 @@ export class TextHarnessBridge {
605
606
  t('{label}机器人已连接 DeepSeek Harness。', { label: this.#descriptor.label }),
606
607
  '',
607
608
  t('直接发送文字、图片或文件即可继续当前会话。'),
608
- t('/new 开启一个全新会话'),
609
- t('/compact 压缩当前会话的较早上下文'),
610
- t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
611
- t('/workspace 工作区序号或绝对路径 切换工作区'),
612
- t('/workspacelist 列出工作区绝对路径'),
613
- t('/ws、/wsl、/workspaces 工作区命令别名'),
614
- t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
615
- t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
616
- t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
617
- t('/models 按序号列出所有可用模型'),
618
- t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
619
- t('/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级'),
620
- t('/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型'),
621
- t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
622
- t('/presetlist 或 /presets 按序号列出可用 Agent Preset'),
623
- t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
624
- t('纯数字 ID:/preset id:<ID>'),
625
- t('/preset --default 跟随 Host 默认'),
626
- t('/stop 停止当前任务'),
627
- t('/steer 补充指令 纠偏当前任务'),
628
- t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
629
- t('/send 提交当前批次'),
630
- t('/cancel 取消当前批次'),
631
- t('/status 检查连接状态'),
632
- t('/version 查看插件版本'),
633
- t('/help 显示本帮助'),
609
+ ...commandHelpLines(this.#descriptor.key),
634
610
  ].join('\n'));
635
611
  return;
636
612
  }