@xmanrui/dsh-im 4.16.0 → 4.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.16.0",
3
+ "version": "4.17.0",
4
4
  "description": "把十种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect ten IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -39,6 +39,10 @@
39
39
  {
40
40
  "name": "BuvkB",
41
41
  "url": "https://github.com/BuvkB"
42
+ },
43
+ {
44
+ "name": "masucc",
45
+ "url": "https://github.com/masucc"
42
46
  }
43
47
  ],
44
48
  "license": "MIT",
@@ -11,6 +11,7 @@ import { normalizeModelCatalog, normalizeModelSelection, SET_MODEL_ENDPOINT } fr
11
11
  import { normalizeLastMessageError } from "../../last-message-error.js";
12
12
  import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-policy.mjs";
13
13
  import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
14
+ import { normalizeFeishuStepPushMode } from "../../../../src/channels/feishu/step-push-mode.mjs";
14
15
 
15
16
  export const FEISHU_RPC_CHANNEL = "/feishu";
16
17
 
@@ -33,6 +34,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
33
34
  setGroupResponseMode: "bot.group-response-mode.set",
34
35
  setGroupTopicReply: "bot.group-topic-reply.set",
35
36
  setStepPush: "bot.step-push.set",
37
+ setStepPushMode: "bot.step-push-mode.set",
36
38
  // Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
37
39
  testConnection: "connection.test",
38
40
  disconnect: "connection.disconnect",
@@ -219,6 +221,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
219
221
  groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
220
222
  groupTopicReply: value.groupTopicReply === true,
221
223
  stepPush: value.stepPush === true,
224
+ stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
222
225
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
223
226
  bot: normalizeBot(value.bot),
224
227
  health: normalizeHealth(value.health, connected),
@@ -464,21 +464,20 @@ function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
464
464
  );
465
465
  }
466
466
 
467
- /** Toggle row for 分步直推, mirroring the group-topic reply toggle pattern. */
468
- function StepPushEditor({ value = false, disabled = false, onSave }) {
467
+ /** One select for the step-push presentation: off / per-step posts / process card. */
468
+ function StepPushEditor({ value = false, mode = "post", disabled = false, onSave, onModeSave }) {
469
469
  const titleId = React.useId();
470
470
  const helpId = `${titleId}-help`;
471
- const current = value === true ? "on" : "off";
471
+ const current = value === true ? mode : "off";
472
472
  const [saving, setSaving] = React.useState(false);
473
473
  const [error, setError] = React.useState(null);
474
474
 
475
- const change = async (event) => {
476
- const next = event.target.value === "on";
477
- if ((next ? "on" : "off") === current || saving || disabled) return;
475
+ const save = async (run) => {
476
+ if (saving || disabled) return;
478
477
  setSaving(true);
479
478
  setError(null);
480
479
  try {
481
- await onSave?.(next);
480
+ await run();
482
481
  } catch (cause) {
483
482
  setError(cause?.message ?? "分步直推设置保存失败,请重试。");
484
483
  } finally {
@@ -486,13 +485,37 @@ function StepPushEditor({ value = false, disabled = false, onSave }) {
486
485
  }
487
486
  };
488
487
 
488
+ const change = (event) => {
489
+ const next = event.target.value;
490
+ if (next === current) return;
491
+ void save(async () => {
492
+ if (next === "off") {
493
+ // Turning off only needs the flag when it was on.
494
+ if (value === true) await onSave?.(false);
495
+ return;
496
+ }
497
+ const nextMode = next === "streaming_card" ? "streaming_card" : "post";
498
+ // Enabling (or switching presentation) may need both writes; the flag
499
+ // must land before the mode so the runtime never sees a mode without
500
+ // step push enabled.
501
+ if (value !== true) await onSave?.(true);
502
+ if (nextMode !== mode) await onModeSave?.(nextMode);
503
+ });
504
+ };
505
+
506
+ const helpText = current === "off"
507
+ ? "适合日常问答:执行过程中不显示工具调用等中间步骤,只回复最终结果"
508
+ : current === "streaming_card"
509
+ ? "推荐长任务使用:过程与最终答案都在同一张卡片里实时更新,不刷屏"
510
+ : "每一步都单独发一条消息(含工具调用和过程说明);注意长任务会连续发送较多消息";
511
+
489
512
  return h("section", {
490
513
  className: "dim-feishuGroupControl",
491
514
  "aria-labelledby": titleId,
492
515
  },
493
516
  h("div", { className: "dim-feishuGroupControlHeader" },
494
517
  h("div", { className: "dim-presetTitle" },
495
- h("h3", { id: titleId }, "分步直推"),
518
+ h("h3", { id: titleId }, "任务过程展示"),
496
519
  h("span", { className: "dim-presetHelp" },
497
520
  h("button", {
498
521
  type: "button",
@@ -504,7 +527,7 @@ function StepPushEditor({ value = false, disabled = false, onSave }) {
504
527
  id: helpId,
505
528
  className: "dim-presetTooltip",
506
529
  role: "tooltip",
507
- }, "开启后逐步推送工具调用与过程说明"))),
530
+ }, "设置任务执行过程的呈现方式:不显示、实时卡片或逐步消息"))),
508
531
  saving
509
532
  ? h("span", { className: "dim-feishuGroupControlStatus", role: "status" }, "保存中…")
510
533
  : null),
@@ -512,11 +535,13 @@ function StepPushEditor({ value = false, disabled = false, onSave }) {
512
535
  className: "dim-feishuGroupSelect",
513
536
  value: current,
514
537
  disabled: disabled || saving,
515
- "aria-label": "分步直推",
516
- onChange: (event) => { void change(event); },
538
+ "aria-label": "任务过程展示",
539
+ onChange: change,
517
540
  },
518
- h("option", { value: "off" }, "关闭(保持流式卡模式)"),
519
- h("option", { value: "on" }, "开启(逐步推送工具调用与过程说明)")),
541
+ h("option", { value: "off" }, "不显示过程(只发送最终答案)"),
542
+ h("option", { value: "streaming_card" }, "实时过程卡(全程一张卡片动态更新)"),
543
+ h("option", { value: "post" }, "逐步直播(每一步单独发一条消息)")),
544
+ h("p", { className: "dim-feishuGroupHelp" }, helpText),
520
545
  error ? h("p", {
521
546
  className: "dim-feishuGroupError",
522
547
  role: "alert",
@@ -539,6 +564,7 @@ export function BotCard({
539
564
  onAgentPresetSave,
540
565
  onContextEnhancementSave,
541
566
  onStepPushSave,
567
+ onStepPushModeSave,
542
568
  onRequestRemove,
543
569
  onConfirmRemove,
544
570
  onCancelRemove,
@@ -627,8 +653,10 @@ export function BotCard({
627
653
  }),
628
654
  h(StepPushEditor, {
629
655
  value: connection.stepPush,
656
+ mode: connection.stepPushMode,
630
657
  disabled: Boolean(busy),
631
658
  onSave: onStepPushSave,
659
+ onModeSave: onStepPushModeSave,
632
660
  }),
633
661
  provisionContent
634
662
  ? h("section", {
@@ -723,6 +751,7 @@ function BotList(props) {
723
751
  onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
724
752
  onContextEnhancementSave: (config) => props.onContextEnhancementSave(bot, config),
725
753
  onStepPushSave: (stepPush) => props.onStepPushSave(bot, stepPush),
754
+ onStepPushModeSave: (stepPushMode) => props.onStepPushModeSave(bot, stepPushMode),
726
755
  onRequestRemove: () => props.onRequestRemove(bot),
727
756
  onConfirmRemove: () => props.onConfirmRemove(bot),
728
757
  onCancelRemove: props.onCancelRemove,
@@ -1500,6 +1529,9 @@ export function FeishuSettingsTab({ rpcCall }) {
1500
1529
  onStepPushSave: (connection, stepPush) => saveBotSetting(
1501
1530
  connection, "step-push", FEISHU_ENDPOINTS.setStepPush, { stepPush },
1502
1531
  ),
1532
+ onStepPushModeSave: (connection, stepPushMode) => saveBotSetting(
1533
+ connection, "step-push-mode", FEISHU_ENDPOINTS.setStepPushMode, { stepPushMode },
1534
+ ),
1503
1535
  onRequestRemove: requestRemove,
1504
1536
  onConfirmRemove: (bot) => void confirmRemove(bot),
1505
1537
  onCancelRemove: cancelRemove,
@@ -223,9 +223,20 @@ const EN = Object.freeze({
223
223
  '分步直推': 'Step push',
224
224
  '查看分步直推说明': 'View step push help',
225
225
  '开启后逐步推送工具调用与过程说明': 'Push tool calls and process notes step by step',
226
- '关闭(保持流式卡模式)': 'Off (keep the streaming card)',
227
- '开启(逐步推送工具调用与过程说明)': 'On (push tool calls and process notes step by step)',
226
+ '任务过程展示': 'Task progress display',
227
+ '设置任务执行过程的呈现方式:不显示、实时卡片或逐步消息': 'Choose how the execution is presented: hidden, one live card, or step-by-step messages',
228
+ '不显示过程(只发送最终答案)': 'Hide the process (send the final answer only)',
229
+ '实时过程卡(全程一张卡片动态更新)': 'Live process card (one card updated throughout)',
230
+ '逐步直播(每一步单独发一条消息)': 'Step-by-step feed (one message per step)',
231
+ '适合日常问答:执行过程中不显示工具调用等中间步骤,只回复最终结果': 'For everyday Q&A: tool calls and other interim steps stay hidden, only the final result is replied',
232
+ '推荐长任务使用:过程与最终答案都在同一张卡片里实时更新,不刷屏': 'Recommended for long tasks: the process and the final answer update live in one card without flooding the chat',
233
+ '每一步都单独发一条消息(含工具调用和过程说明);注意长任务会连续发送较多消息': 'Every step is sent as its own message (including tool calls and notes); long tasks may send many messages in a row',
228
234
  '分步直推设置保存失败,请重试。': 'Could not save the step push setting. Try again.',
235
+ 'zcode过程卡模式': 'ZCode process-card mode',
236
+ '过程说明、工具摘要与最终答案都在同一张卡片中原地刷新':
237
+ 'Process notes, the tool summary, and the final answer refresh in place inside one card',
238
+ '思考与工具摘要折叠展示,最终答案在同一张卡片中原地刷新':
239
+ 'Thinking and the tool summary stay folded; the final answer refreshes in place inside one card',
229
240
  '钉钉设置': 'DingTalk settings',
230
241
  '企业微信设置': 'WeCom settings',
231
242
  '扫码接入机器人': 'Scan QR code',
@@ -219,6 +219,7 @@ export async function createProductionController(ctx, config = {}, internals = {
219
219
  groupResponseMode: botConfig.groupResponseMode,
220
220
  groupTopicReply: botConfig.groupTopicReply,
221
221
  stepPush: botConfig.stepPush,
222
+ stepPushMode: botConfig.stepPushMode,
222
223
  ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
223
224
  harness: workspaceScope.harness,
224
225
  state: workspaceScope.state,
@@ -21,6 +21,10 @@ import {
21
21
  isFeishuGroupResponseMode,
22
22
  normalizeFeishuGroupResponseMode,
23
23
  } from '../../../../src/channels/feishu/group-response-mode.mjs';
24
+ import {
25
+ isFeishuStepPushMode,
26
+ normalizeFeishuStepPushMode,
27
+ } from '../../../../src/channels/feishu/step-push-mode.mjs';
24
28
  import {
25
29
  FEISHU_ENDPOINTS as FEISHU_CLIENT_ENDPOINTS,
26
30
  FEISHU_RPC_CHANNEL,
@@ -295,6 +299,7 @@ function publicBotEntry(entry) {
295
299
  groupResponseMode: normalizeFeishuGroupResponseMode(source.groupResponseMode),
296
300
  groupTopicReply: source.groupTopicReply === true,
297
301
  stepPush: source.stepPush === true,
302
+ stepPushMode: normalizeFeishuStepPushMode(source.stepPushMode),
298
303
  groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
299
304
  bot: publicBot(source.bot),
300
305
  health: publicHealth(source, connected),
@@ -466,6 +471,13 @@ function validPayload(endpoint, payload) {
466
471
  ? null
467
472
  : '请选择是否分步直推。';
468
473
  }
474
+ if (endpoint === FEISHU_ENDPOINTS.setStepPushMode) {
475
+ return hasOnlyKeys(payload, new Set(['botId', 'stepPushMode']))
476
+ && safeOpaqueId(payload.botId)
477
+ && isFeishuStepPushMode(payload.stepPushMode)
478
+ ? null
479
+ : '请选择分步直推的呈现方式。';
480
+ }
469
481
  return 'Unknown Feishu endpoint.';
470
482
  }
471
483
 
@@ -758,6 +770,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
758
770
  await controller.updateStepPush(payload.botId, payload.stepPush),
759
771
  { encodeQr: cachedEncodeQr },
760
772
  );
773
+ } else if (endpoint === FEISHU_ENDPOINTS.setStepPushMode) {
774
+ if (typeof controller.updateStepPushMode !== 'function') {
775
+ throw new Error('Step push mode update is unavailable');
776
+ }
777
+ value = await toPublicFeishuStatus(
778
+ await controller.updateStepPushMode(payload.botId, payload.stepPushMode),
779
+ { encodeQr: cachedEncodeQr },
780
+ );
761
781
  } else {
762
782
  if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
763
783
  value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
@@ -337,10 +337,13 @@ function cardMarkdown(text, target) {
337
337
  }
338
338
 
339
339
  function cardData(text, flowStatus, target) {
340
+ const markdown = cardMarkdown(text, target);
341
+ // The shared template uses msgContent for processing, finished, and
342
+ // failed cards. Switching its order to staticMsgContent hides the reply.
340
343
  return {
341
344
  cardParamMap: {
342
345
  flowStatus,
343
- msgContent: cardMarkdown(text, target),
346
+ msgContent: markdown,
344
347
  staticMsgContent: '',
345
348
  sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
346
349
  config: JSON.stringify({ autoLayout: true }),
@@ -922,13 +925,17 @@ export function createDingtalkApi({
922
925
 
923
926
  let delivered = false;
924
927
  try {
925
- await cardRequest('v1.0/card/instances', {
928
+ // Deliver the thinking copy in the same request that first shows the
929
+ // card. Creating an empty instance and filling it after deliver (the
930
+ // previous three-request sequence) leaves a blank bubble visible in
931
+ // DingTalk for the time between deliver and the follow-up PUT, and
932
+ // leaves a permanently blank card if that PUT ever fails.
933
+ await cardRequest('v1.0/card/instances/createAndDeliver', {
926
934
  body: {
935
+ ...cardDeliverBody(cardInstanceId, normalizedTarget, appKey),
927
936
  cardTemplateId: DINGTALK_AI_CARD_TEMPLATE_ID,
928
937
  outTrackId: cardInstanceId,
929
- cardData: {
930
- cardParamMap: { config: JSON.stringify({ autoLayout: true }) },
931
- },
938
+ cardData: cardData(content, '2', normalizedTarget),
932
939
  callbackType: 'STREAM',
933
940
  cardAtUserIds: normalizedTarget.atUserIds
934
941
  ? Object.keys(normalizedTarget.atUserIds)
@@ -938,37 +945,31 @@ export function createDingtalkApi({
938
945
  },
939
946
  headers,
940
947
  signal,
941
- action: 'AI Card 创建',
942
- });
943
- await cardRequest('v1.0/card/instances/deliver', {
944
- body: cardDeliverBody(cardInstanceId, normalizedTarget, appKey),
945
- headers,
946
- signal,
947
- action: 'AI Card 投放',
948
+ action: 'AI Card 创建并投放',
948
949
  });
949
950
  delivered = true;
950
- await cardRequest('v1.0/card/instances', {
951
- method: 'PUT',
952
- body: { outTrackId: cardInstanceId, cardData: cardData(content, '2', normalizedTarget) },
953
- headers,
954
- signal,
955
- action: 'AI Card 启动',
956
- });
957
- await cardRequest('v1.0/card/streaming', {
958
- method: 'PUT',
959
- body: {
960
- outTrackId: cardInstanceId,
961
- guid: randomUUID(),
962
- key: 'msgContent',
963
- content: cardMarkdown(content, normalizedTarget).replace(/\n+$/, ''),
964
- isFull: true,
965
- isFinalize: false,
966
- isError: false,
967
- },
968
- headers,
969
- signal,
970
- action: 'AI Card 启动',
971
- });
951
+ try {
952
+ await cardRequest('v1.0/card/streaming', {
953
+ method: 'PUT',
954
+ body: {
955
+ outTrackId: cardInstanceId,
956
+ guid: randomUUID(),
957
+ key: 'msgContent',
958
+ content: cardMarkdown(content, normalizedTarget).replace(/\n+$/, ''),
959
+ isFull: true,
960
+ isFinalize: false,
961
+ isError: false,
962
+ },
963
+ headers,
964
+ signal,
965
+ action: 'AI Card 启动',
966
+ });
967
+ } catch (error) {
968
+ if (signal?.aborted) throw error;
969
+ // The card is already visible with the thinking copy from
970
+ // createAndDeliver above. Keep the instance so finishAiCard can
971
+ // still replace it in place instead of sending a second message.
972
+ }
972
973
  } catch (error) {
973
974
  if (delivered) {
974
975
  const cleanupSignal = AbortSignal.timeout(5_000);
@@ -1018,6 +1019,8 @@ export function createDingtalkApi({
1018
1019
  const token = await accessToken({ clientId, clientSecret, signal });
1019
1020
  const headers = { 'x-acs-dingtalk-access-token': token };
1020
1021
  const normalizedContent = cardMarkdown(content, target);
1022
+ // Close the streaming widget before persisting the template's
1023
+ // finished state and full answer in msgContent.
1021
1024
  await cardRequest('v1.0/card/streaming', {
1022
1025
  method: 'PUT',
1023
1026
  body: {
@@ -1033,8 +1036,7 @@ export function createDingtalkApi({
1033
1036
  signal,
1034
1037
  action: 'AI Card 完成',
1035
1038
  });
1036
- let completed = true;
1037
- const completionRequest = {
1039
+ await cardRequest('v1.0/card/instances', {
1038
1040
  method: 'PUT',
1039
1041
  body: {
1040
1042
  outTrackId: instanceId,
@@ -1043,18 +1045,9 @@ export function createDingtalkApi({
1043
1045
  },
1044
1046
  headers,
1045
1047
  signal,
1046
- action: 'AI Card 收口',
1047
- };
1048
- try {
1049
- await cardRequest('v1.0/card/instances', completionRequest);
1050
- } catch {
1051
- try {
1052
- await cardRequest('v1.0/card/instances', completionRequest);
1053
- } catch {
1054
- completed = false;
1055
- }
1056
- }
1057
- return { delivered: true, completed };
1048
+ action: 'AI Card 完成状态',
1049
+ });
1050
+ return { delivered: true, completed: true };
1058
1051
  },
1059
1052
 
1060
1053
  failAiCard: failCard,
@@ -1336,6 +1336,8 @@ export class DingtalkHarnessBridge {
1336
1336
  providerMessageIds: cardStream.providerMessageIds,
1337
1337
  });
1338
1338
  } else {
1339
+ // Failed streams close the card with a notice pointing to a
1340
+ // follow-up message. Deliver the answer through that fallback.
1339
1341
  textReceipt = createDeliveryReceipt({
1340
1342
  deliveryId: messageId,
1341
1343
  presentation: 'dingtalk-text',
@@ -1411,7 +1413,9 @@ export class DingtalkHarnessBridge {
1411
1413
  ? `${errorText}\n\n${batchFailureMessage}`
1412
1414
  : errorText;
1413
1415
  const streamed = cardStarted && await cardStream.finish(visibleError);
1414
- if (!streamed) await this.#send(sessionWebhook, visibleError, this.#atUsersFor(message));
1416
+ if (!streamed) {
1417
+ await this.#send(sessionWebhook, visibleError, this.#atUsersFor(message));
1418
+ }
1415
1419
  } catch {
1416
1420
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
1417
1421
  }