@jeik/dingtalk-connector 0.8.21-fix2 → 0.8.21-fix20

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 CHANGED
@@ -25,6 +25,11 @@
25
25
 
26
26
  | Date | Tag | Update |
27
27
  |------|------|--------|
28
+ | 2026-06-29 | ✨ | **Answer-card mode** (on by default): when the final answer exceeds `answerActToken` (default 600) tokens, the streaming card finalizes to "✅ Done thinking" and the full reply is delivered on a separate **static answer card**, sidestepping DingTalk's official bug where a FINISHED streaming card keeps flickering; short answers stay on the original card. Template/threshold configurable (`answerCardTemplateId` / `answerActToken`) |
29
+ | 2026-06-29 | ✨ | **Tool-call progress**: while a tool runs, the card streams `🔧 Calling tool: <name>`, then updates to the reply when done |
30
+ | 2026-06-29 | 🐛 | **Fixed tool failures being treated as the final answer**: failed tool results (carrying `isError`/`isStatusNotice`, e.g. dws) were occasionally taken as the final answer and stopped rendering early; now excluded per OpenClaw's own rule — shown transiently, never counted as the answer |
31
+ | 2026-06-29 | 🎯 | **Reply marker system**: works with prompt-rewriter's `[-process-]`/`[-final-]` markers — process segments stream token-by-token, `[-final-]` triggers one-shot finalize (no "fake-stream replay"); markers never shown to the user, and take priority over OpenClaw's default fallback |
32
+ | 2026-06-29 | 🔧 | Install wizard: `getInstallSpec` now pins the exact version (fixes "installing the fix build but getting the stable one"); asks whether to skip dws update when already installed; detects & can disable a local plugin copy shadowing the npm version |
28
33
  | 2026-06-28 | ✨ | Install wizard now detects existing config: skip QR when a bot already exists; after QR, choose to overwrite or add a bot (bindings maintained automatically, other configs untouched) |
29
34
  | 2026-06-28 | 📦 | Now published to npm (`@jeik/dingtalk-connector`) with one-command scan-to-install; `--force` overwrites for updates, no uninstall needed |
30
35
  | 2026-06-28 | 🐛 | Fixed the connector mistaking an intermediate progress message for the final answer and ending AI Card rendering too early when the model emits multiple progress messages in one turn (card now finalized only at turn end) |
@@ -68,11 +73,30 @@ Full update log: [FIXES.md](FIXES.md)([🇨🇳 中文](FIXES.en.md))
68
73
  | `cardContentVar` | Final response content variable, defaults to `msgContent` |
69
74
  | `cardProcessVar` | Intermediate process (block status) variable, defaults to `cardContentVar` if not set |
70
75
  | `cardToolVar` | Tool call output variable, not written to card if not set |
76
+ | `answerCard` | Answer-card mode switch, **on by default**; set `false` to disable |
77
+ | `answerActToken` | Answer-card trigger threshold (tokens), default `600`; final answer ≤ this stays on the original card, > this opens a separate answer card |
78
+ | `answerCardTemplateId` | Answer-card template ID, uses the built-in default if not set (must contain a `content` variable) |
71
79
 
72
80
  > Card template must be created in [DingTalk Open Platform](https://open.dingtalk.com/) with matching variable fields.
73
81
 
74
82
  ---
75
83
 
84
+ ## 🎯 Reply markers + Answer card + Tool progress (core enhancements)
85
+
86
+ These make the "process → final answer" rendering on DingTalk cleaner and more stable, working around DingTalk's official streaming AI Card bug:
87
+
88
+ - **Reply markers**: work with the `[-process-]` (process) / `[-final-]` (final) markers injected by [prompt-rewriter](https://www.npmjs.com/package/@jeik/prompt-rewriter).
89
+ - Process segments stream token-by-token; once `[-final-]` appears, streaming stops and the card is **finalized in one shot** (no DingTalk "fake-stream replay").
90
+ - Markers are **never visible to the user** (stripped before writing to the card) and take **priority over** OpenClaw's default fallback — preventing intermediate process text from being mistaken for the final answer and stopping rendering early.
91
+ - With no markers, behavior follows OpenClaw's default entirely.
92
+ - **Answer-card mode** (on by default): when the final answer exceeds `answerActToken` (default 600) tokens, the **streaming card finalizes to "✅ Done thinking"** and a separate **static answer card** carries the full reply — avoiding DingTalk's bug where a FINISHED streaming card keeps flickering/re-rendering. Short answers stay on the original card, no extra card.
93
+ - **Tool-call progress**: while the Agent calls a tool, the card streams `🔧 Calling tool: <name>`, then updates to the reply when the tool finishes.
94
+ - **Tool failures no longer mis-rendered**: failed tool results (carrying `isError`/`isStatusNotice`) are shown transiently but **never treated as the final answer**.
95
+
96
+ > Markers require the prompt-rewriter plugin; answer card / tool progress are built into the connector and work out of the box.
97
+
98
+ ---
99
+
76
100
  ## Why Fork?
77
101
 
78
102
  Due to the sluggish pace of the official DingTalk connector's updates and bug fixes, this repository was forked to keep up with critical fixes.
package/README.md CHANGED
@@ -25,6 +25,11 @@
25
25
 
26
26
  | 日期 | 标识 | 更新内容 |
27
27
  |------|------|---------|
28
+ | 2026-06-29 | ✨ | **答案卡模式**(默认开启):最终答案超过 `answerActToken`(默认 600)token 时,原流式卡定格"✅ 思考完成"、另投一张**静态答案卡**,规避钉钉流式卡 FINISHED 后仍抖动的官方 bug;短答案仍在原卡定稿。模板/阈值可配(`answerCardTemplateId` / `answerActToken`) |
29
+ | 2026-06-29 | ✨ | **工具调用进度**:调用工具时原卡流式显示 `🔧 正在调用工具:<工具名>`,结束后正常更新为回复 |
30
+ | 2026-06-29 | 🐛 | **修复工具调用失败被当最终答案**:dws 等工具失败结果(带 `isError`/`isStatusNotice`)以前偶发被当最终答案、提前停渲染;现按 OpenClaw 官方标准排除,仅展示不计入答案 |
31
+ | 2026-06-29 | 🎯 | **回复标记系统**:配合 prompt-rewriter 的 `[-process-]`/`[-final-]` 标记——过程段逐字流式、出现 `[-final-]` 一次性定稿(去掉"假流式回放");标记不展示给用户,优先级高于 OpenClaw 默认兜底 |
32
+ | 2026-06-29 | 🔧 | 安装向导:`getInstallSpec` 钉死精确版本(修复"装修复版却被装成正式版");dws 已安装时询问是否跳过更新;检测并可禁用遮蔽 npm 版的本地插件副本 |
28
33
  | 2026-06-28 | ✨ | 安装向导新增「已存在配置」检测:已有机器人配置可跳过扫码;扫码后可选覆盖或新增机器人(自动维护 bindings、不覆盖其它配置) |
29
34
  | 2026-06-28 | 📦 | 改为发布到 npm(`@jeik/dingtalk-connector`),新增一键扫码安装命令;`--force` 覆盖更新无需卸载 |
30
35
  | 2026-06-28 | 🐛 | 修复模型一轮内发送多条过程消息时,连接器把中间过程消息当成最终答案、提前结束 AI Card 渲染的问题(改为整轮结束才定稿卡片) |
@@ -104,9 +109,28 @@
104
109
  | `cardContentVar` | 最终回复内容变量名,不填默认 `msgContent` |
105
110
  | `cardProcessVar` | 中间过程(block 状态)变量名,不填默认使用 `cardContentVar` |
106
111
  | `cardToolVar` | 工具调用输出变量名,不填则不写入卡片 |
112
+ | `answerCard` | 答案卡模式开关,**默认开启**;显式设 `false` 关闭 |
113
+ | `answerActToken` | 答案卡触发阈值(token),默认 `600`;最终答案 ≤ 此值直接在原卡定稿,> 此值才另开答案卡 |
114
+ | `answerCardTemplateId` | 答案卡模板 ID,不填用内置默认模板(需含 `content` 变量) |
107
115
 
108
116
  > 卡片模板需在[钉钉开放平台](https://open.dingtalk.com/)创建,并添加对应的变量字段。
109
117
 
118
+ ---
119
+
120
+ ## 🎯 回复标记 + 答案卡 + 工具进度(核心增强)
121
+
122
+ 这套机制让钉钉侧的「过程 → 最终答案」渲染更干净、更稳定,规避钉钉流式 AI Card 的官方渲染 bug:
123
+
124
+ - **回复标记**:配合 [prompt-rewriter](https://www.npmjs.com/package/@jeik/prompt-rewriter) 注入的 `[-process-]`(过程段)/`[-final-]`(最终答案)标记。
125
+ - 过程段逐字流式滚动;出现 `[-final-]` 后**停止流式、一次性定稿**(去掉钉钉"假流式回放")。
126
+ - 标记对用户**完全不可见**(进卡前统一剥离),且**优先级高于** OpenClaw 默认兜底——避免中间过程被误判成最终答案、提前停渲染。
127
+ - 无标记时完全走 OpenClaw 默认逻辑。
128
+ - **答案卡模式**(默认开启):最终答案 token 超过 `answerActToken`(默认 600)时,**原流式卡定格"✅ 思考完成"**,另投一张**静态答案卡**承载完整回复——规避钉钉流式卡 FINISHED 后仍抖动/重渲染的 bug。短答案仍在原卡定稿,不多开卡。
129
+ - **工具调用进度**:Agent 调用工具时,原卡流式显示 `🔧 正在调用工具:<工具名>`,工具结束后正常更新为回复。
130
+ - **工具失败不再误判**:工具调用失败的结果(带 `isError`/`isStatusNotice`)只在卡片短暂展示,**不会被当成最终答案**。
131
+
132
+ > 标记功能需安装并启用 prompt-rewriter 插件;答案卡/工具进度为连接器内置,开箱即用。
133
+
110
134
  **效果预览:**
111
135
 
112
136
  ![自定义卡片效果](assets/image.png)
@@ -7,7 +7,7 @@
7
7
  * node bin/dingtalk-connector.js install --local # local dev
8
8
  */
9
9
  import { createRequire } from 'node:module';
10
- import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
10
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync, renameSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import {
@@ -294,16 +294,68 @@ function saveCredentials(clientId, clientSecret, { isLocal = false, pluginInstal
294
294
 
295
295
  // ── plugin install ─────────────────────────────────────────────
296
296
  function getInstallSpec() {
297
- // Read version from own package.json to pass the exact version to openclaw
297
+ // 永远钉住"本包自己的精确版本",让 openclaw 内部装的版本 == 你 npx 实际下到的版本。
298
+ // npx @fix / @0.8.21-fix7 / @latest / 不带 → 各自下到对应包 → 这里读到的就是那个版本。
299
+ // 不带版本号会让 openclaw 装 latest,导致"跑 fix 向导却装了正式版"那个坑。
298
300
  try {
299
- const require = createRequire(import.meta.url);
300
- const { version } = require('../package.json');
301
- if (version && /-(alpha|beta|rc|canary)/.test(version)) {
302
- // prerelease → use exact version so openclaw accepts it
303
- return `${PKG_NAME}@${version}`;
304
- }
301
+ const { version } = createRequire(import.meta.url)('../package.json');
302
+ if (version) return `${PKG_NAME}@${version}`;
305
303
  } catch {}
306
- return PKG_NAME;
304
+ return PKG_NAME; // 兜底:读不到版本才退回不带版本(极少见)
305
+ }
306
+
307
+ // 在 plugins.load.paths 扫描范围内找会"遮蔽"npm 版的本地 dingtalk 插件副本。
308
+ // 原理:load.paths 扫到的插件 origin=config,优先级(rank 0)高于 npm 安装的 global(rank 2),
309
+ // 所以一份本地克隆会永久压过 npm 版,--force 重装也没用。这里浅扫一层定位它。
310
+ // ponytail: 只扫 load.paths 下一层目录;克隆嵌套更深的极少见,需要再加深度。
311
+ function findShadowingLocalPlugin(paths = readConfig()?.plugins?.load?.paths) {
312
+ if (!Array.isArray(paths)) return null;
313
+ const npmRoot = join(homedir(), '.openclaw', 'npm');
314
+ for (const p of paths) {
315
+ if (typeof p !== 'string') continue;
316
+ const base = p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
317
+ let entries;
318
+ try { entries = readdirSync(base, { withFileTypes: true }); } catch { continue; }
319
+ for (const e of entries) {
320
+ if (!e.isDirectory()) continue;
321
+ // OpenClaw 扫描会跳过这些名字(discovery shouldIgnoreScannedDirectory),它们不会遮蔽,
322
+ // 已禁用的 .disabled 目录也在此跳过,避免重复禁用成 .disabled.disabled。
323
+ const lower = e.name.toLowerCase();
324
+ if (lower.endsWith('.bak') || lower.includes('.backup-') || lower.includes('.disabled')) continue;
325
+ const dir = join(base, e.name);
326
+ if (dir.startsWith(npmRoot)) continue; // npm 安装目录本身不算遮蔽
327
+ try {
328
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
329
+ const channels = pkg?.openclaw?.channels;
330
+ if (pkg?.name === PKG_NAME || (Array.isArray(channels) && channels.includes(CHANNEL_ID))) {
331
+ return dir;
332
+ }
333
+ } catch {}
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ // 检测并(经确认后)禁用遮蔽 npm 版的本地副本:把目录重命名为 .disabled(可随时改回)。
340
+ async function resolvePluginShadow() {
341
+ const shadow = findShadowingLocalPlugin();
342
+ if (!shadow) return;
343
+ console.log('\n' + orange('⚠ 检测到本地插件副本会覆盖刚装的 npm 版:'));
344
+ console.log(dim(` ${shadow}`));
345
+ console.log(dim(' 它在 plugins.load.paths 扫描范围内,优先级高于 npm 版,网关会一直用这份旧本地代码。') + '\n');
346
+ const ans = await askUserConfirmation(' 是否禁用它(重命名为 .disabled,随时可改回)?[Y/n] ');
347
+ if (ans === 'n' || ans === 'no') {
348
+ console.log(dim(' 已跳过。网关仍会用本地版;如需用 npm 版,请手动移走该目录或从 plugins.load.paths 移除其父路径。') + '\n');
349
+ return;
350
+ }
351
+ const disabled = shadow + '.disabled';
352
+ try {
353
+ if (existsSync(disabled)) rmSync(disabled, { recursive: true, force: true });
354
+ renameSync(shadow, disabled);
355
+ console.log(green(` ✔ 已禁用:${shadow} → ${disabled}`) + '\n');
356
+ } catch (err) {
357
+ console.log(red(` ⚠ 重命名失败:${err.message}。请手动处理该目录。`) + '\n');
358
+ }
307
359
  }
308
360
 
309
361
  function installPlugin(force = false) {
@@ -611,14 +663,21 @@ async function ensureDwsCli() {
611
663
  const versionDisplay = installedVersion ? `v${installedVersion}` : 'unknown version';
612
664
 
613
665
  if (!targetVersion) {
614
- // @latest 模式:解析不到固定版本号 → 始终重装拉最新(latest 不会降级,无需比较/询问)
615
- console.log(dim(` ℹ dws CLI detected (${versionDisplay}), ensuring latest (@latest)...`) + '\n');
616
- const ok = installDwsCli();
617
- if (ok) {
618
- const nv = getInstalledDwsVersion();
619
- console.log(green(` ✔ dws CLI is now ${nv ? 'v' + nv : 'latest'}`) + '\n');
666
+ // @latest 模式:已安装则询问是否跳过更新(默认跳过,保留当前版本)
667
+ const ans = await askUserConfirmation(
668
+ ` 检测到 dws CLI 已安装 (${versionDisplay}),是否跳过更新?(skip dws update?) [Y/n] `,
669
+ );
670
+ if (ans === 'n' || ans === 'no') {
671
+ console.log(dim(` 正在更新 dws CLI 到最新版...`) + '\n');
672
+ const ok = installDwsCli();
673
+ if (ok) {
674
+ const nv = getInstalledDwsVersion();
675
+ console.log(green(` ✔ dws CLI is now ${nv ? 'v' + nv : 'latest'}`) + '\n');
676
+ } else {
677
+ console.log(red(' ⚠ Update failed. Continuing with current version.') + '\n');
678
+ }
620
679
  } else {
621
- console.log(red(' ⚠ Update failed. Continuing with current version.') + '\n');
680
+ console.log(dim(` 已跳过更新,保留当前版本 ${versionDisplay}。`) + '\n');
622
681
  }
623
682
  // 鉴权状态检查(与下方同逻辑)
624
683
  if (isDwsAuthenticated()) {
@@ -731,10 +790,18 @@ Options:
731
790
  globalThis['proc' + 'ess'].exit(1);
732
791
  }
733
792
 
793
+ // 打印当前安装向导版本(= 本包版本),方便确认装的是哪一版
794
+ try {
795
+ const { version } = createRequire(import.meta.url)('../package.json');
796
+ console.log('\n' + cyan(`${PKG_NAME} v${version}`) + dim(' (install wizard)'));
797
+ } catch {}
798
+
734
799
  // Step 1: Install connector plugin (unless --local)
735
800
  let pluginInstalled = true;
736
801
  if (!isLocal) {
737
802
  pluginInstalled = installPlugin(force);
803
+ // 安装成功后检查是否有本地副本会遮蔽 npm 版(load.paths 优先级更高),有则确认禁用
804
+ if (pluginInstalled) await resolvePluginShadow();
738
805
  } else {
739
806
  console.log('\n' + dim('📦 --local mode: skipping plugin install') + '\n');
740
807
  }
@@ -23,7 +23,7 @@ var entry_bundled_default = defineBundledChannelEntry({
23
23
  exportName: "setDingtalkRuntime"
24
24
  },
25
25
  async registerFull(api) {
26
- const { registerGatewayMethods } = await import("./gateway-methods-Ci31A3vg.mjs");
26
+ const { registerGatewayMethods } = await import("./gateway-methods-B0DOSk85.mjs");
27
27
  registerGatewayMethods(api);
28
28
  }
29
29
  });
@@ -1,2 +1,2 @@
1
- import { t as registerGatewayMethods } from "./gateway-methods-C4tcgI7P.mjs";
1
+ import { t as registerGatewayMethods } from "./gateway-methods-C_-LGxry.mjs";
2
2
  export { registerGatewayMethods };
@@ -1,7 +1,7 @@
1
1
  import { a as resolveDingtalkAccount, t as listDingtalkAccountIds } from "./accounts-BAzdqkAV.mjs";
2
2
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
3
3
  import { r as getAccessToken, t as DINGTALK_API } from "./utils-CIfI_3Jh.mjs";
4
- import { c as prepareMultiBotMentions, i as sendProactive, s as buildBotMentionTable, u as finishAICard } from "./messaging-B6l1sRvX.mjs";
4
+ import { c as prepareMultiBotMentions, d as finishAICard, i as sendProactive, s as buildBotMentionTable } from "./messaging-CwnCFf5X.mjs";
5
5
  import { c as getUnionId, d as recallEmotionReply } from "./utils-legacy-CALCPP1t.mjs";
6
6
  //#region src/docs.ts
7
7
  var DingtalkDocsClient = class {
package/dist/index.d.mts CHANGED
@@ -78,6 +78,9 @@ declare const DingtalkConfigSchema: z$1.ZodObject<{
78
78
  cardContentVar: z$1.ZodDefault<z$1.ZodOptional<z$1.ZodString>>;
79
79
  cardProcessVar: z$1.ZodOptional<z$1.ZodString>;
80
80
  cardToolVar: z$1.ZodOptional<z$1.ZodString>;
81
+ answerCard: z$1.ZodOptional<z$1.ZodBoolean>;
82
+ answerActToken: z$1.ZodOptional<z$1.ZodNumber>;
83
+ answerCardTemplateId: z$1.ZodOptional<z$1.ZodString>;
81
84
  enabled: z$1.ZodOptional<z$1.ZodBoolean>;
82
85
  name: z$1.ZodOptional<z$1.ZodString>;
83
86
  clientId: z$1.ZodOptional<z$1.ZodUnion<readonly [z$1.ZodString, z$1.ZodNumber]>>;
@@ -133,6 +136,9 @@ declare const DingtalkConfigSchema: z$1.ZodObject<{
133
136
  cardContentVar: z$1.ZodDefault<z$1.ZodOptional<z$1.ZodString>>;
134
137
  cardProcessVar: z$1.ZodOptional<z$1.ZodString>;
135
138
  cardToolVar: z$1.ZodOptional<z$1.ZodString>;
139
+ answerCard: z$1.ZodOptional<z$1.ZodBoolean>;
140
+ answerActToken: z$1.ZodOptional<z$1.ZodNumber>;
141
+ answerCardTemplateId: z$1.ZodOptional<z$1.ZodString>;
136
142
  enabled: z$1.ZodOptional<z$1.ZodBoolean>;
137
143
  defaultAccount: z$1.ZodOptional<z$1.ZodString>;
138
144
  clientId: z$1.ZodOptional<z$1.ZodUnion<readonly [z$1.ZodString, z$1.ZodNumber]>>;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { a as initDingtalkPluginConfigSchema, i as dingtalkPlugin, n as setDingtalkRuntime } from "./runtime-DUgpo5zC.mjs";
2
- import { t as registerGatewayMethods } from "./gateway-methods-C4tcgI7P.mjs";
1
+ import { a as initDingtalkPluginConfigSchema, i as dingtalkPlugin, n as setDingtalkRuntime } from "./runtime-BwR6sJlg.mjs";
2
+ import { t as registerGatewayMethods } from "./gateway-methods-C_-LGxry.mjs";
3
3
  //#region index.ts
4
4
  /**
5
5
  * 检测同一 plugin id 在多个路径被加载的情况。
@@ -1,10 +1,10 @@
1
1
  import { u as uploadMediaToDingTalk } from "./media-cz72EVS3.mjs";
2
2
  import { a as resolveDingtalkAccount } from "./accounts-BAzdqkAV.mjs";
3
- import { r as CHANNEL_ID, t as getDingtalkRuntime } from "./runtime-DUgpo5zC.mjs";
3
+ import { r as CHANNEL_ID, t as getDingtalkRuntime } from "./runtime-BwR6sJlg.mjs";
4
4
  import { n as createLoggerFromConfig } from "./logger-mZ9OSbmD.mjs";
5
5
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
6
6
  import { i as getOapiAccessToken } from "./utils-CIfI_3Jh.mjs";
7
- import { a as sendTextMessage, f as isQpsLimitError, h as unregisterActiveCard, i as sendProactive, l as createAICardForTarget, m as streamAICard, p as registerActiveCard, r as sendMessage, t as sendMarkdownMessage, u as finishAICard } from "./messaging-B6l1sRvX.mjs";
7
+ import { S as finalClean, _ as FINAL_TAG, a as sendTextMessage, b as estimateTokens, d as finishAICard, g as unregisterActiveCard, h as streamAICard, i as sendProactive, m as registerActiveCard, p as isQpsLimitError, r as sendMessage, t as sendMarkdownMessage, u as createAICardForTarget, v as PROCESS_TAG, x as extractFinal, y as displayClean } from "./messaging-CwnCFf5X.mjs";
8
8
  import { a as QUEUE_BUSY_ACK_PHRASES, n as normalizeSlashCommand, t as buildSessionContext } from "./session-DJ4jYqPv.mjs";
9
9
  import { d as recallEmotionReply, o as getAccessToken, r as addEmotionReply, s as getOapiAccessToken$1, t as DINGTALK_API } from "./utils-legacy-CALCPP1t.mjs";
10
10
  import "./chunk-upload-BBQgGtcZ.mjs";
@@ -204,42 +204,6 @@ async function uploadAndReplaceFileMarkers(content, sessionWebhook, config, oapi
204
204
  return result;
205
205
  }
206
206
  //#endregion
207
- //#region src/services/reply-markers.ts
208
- const PROCESS_TAG = "[-process-]";
209
- const FINAL_TAG = "[-final-]";
210
- const EDGE_WS = /^[ \t\r\n]+|[ \t\r\n]+$/g;
211
- /** 取最终答案:去掉那一个 [-final-](开头/中间/结尾都行),其余原样,首尾空白清掉。
212
- * 没有 [-final-] 返回 null。多处出现取最后一个当信号。 */
213
- function extractFinal(text) {
214
- if (!text) return null;
215
- const i = text.lastIndexOf(FINAL_TAG);
216
- if (i < 0) return null;
217
- return (text.slice(0, i) + text.slice(i + 9)).replace(EDGE_WS, "");
218
- }
219
- /** 剥掉一个标记 token([-process-] 或 [-final-],位置无关,取最后一个),其余原样,首尾空白清掉。 */
220
- function stripOneMarker(text) {
221
- if (!text) return "";
222
- const pi = text.lastIndexOf(PROCESS_TAG);
223
- const fi = text.lastIndexOf(FINAL_TAG);
224
- if (pi < 0 && fi < 0) return text.replace(EDGE_WS, "");
225
- const [i, len] = fi >= pi ? [fi, 9] : [pi, 11];
226
- return (text.slice(0, i) + text.slice(i + len)).replace(EDGE_WS, "");
227
- }
228
- /** 去掉流式途中末尾未闭合的半截标记(如 "[-fin",缺右 "]")。完整标记交给上面两个函数。 */
229
- function stripPartialTail(text) {
230
- if (!text) return "";
231
- return text.replace(/\[-[a-z]*-?$/i, "");
232
- }
233
- /** 定稿用:有 [-final-] 去掉它;否则剥一个标记。正文不动。 */
234
- function finalClean(text) {
235
- return extractFinal(text) ?? stripOneMarker(text);
236
- }
237
- /** 流式展示用:有 [-final-] 直接显示最终答案;否则剥一个标记 + 半截标记,正文不动。 */
238
- function displayClean(text) {
239
- const fin = extractFinal(text);
240
- return fin !== null ? fin : stripPartialTail(stripOneMarker(text));
241
- }
242
- //#endregion
243
207
  //#region src/reply-dispatcher.ts
244
208
  const { createReplyPrefixOptions, createTypingCallbacks, logTypingFailure } = await import("openclaw/plugin-sdk/channel-runtime");
245
209
  function createDingtalkReplyDispatcher(params) {
@@ -265,24 +229,34 @@ function createDingtalkReplyDispatcher(params) {
265
229
  let lastAnswerText = "";
266
230
  let processMarkerLogged = false;
267
231
  let finalMarkerLogged = false;
268
- const observeReply = (raw, isReasoning) => {
232
+ let markerSystemActive = false;
233
+ const isNonAnswerPayload = (p) => Boolean(p) && Boolean(p.isError || p.isStatusNotice || p.isCompactionNotice || p.isFallbackNotice);
234
+ const observeReply = (raw, payload) => {
269
235
  const text = raw ?? "";
270
236
  if (!text) return;
271
- if (!processMarkerLogged && text.includes("[-process-]")) {
272
- processMarkerLogged = true;
273
- log.info(`[DingTalk][marker] 检测到过程标记 ${PROCESS_TAG}(已剥离,不展示给用户)`);
274
- }
237
+ if (isNonAnswerPayload(payload)) return;
238
+ const isReasoning = payload?.isReasoning;
275
239
  const fin = extractFinal(text);
276
240
  if (fin !== null) {
277
241
  if (!finalMarkerLogged) {
278
242
  finalMarkerLogged = true;
279
- log.info(`[DingTalk][marker] 检测到最终标记 ${FINAL_TAG}(已剥离,以其后内容为最终答案)`);
243
+ log.info(`[DingTalk][marker] 检测到 ${FINAL_TAG},最终答案=剥光标记的完整正文,跳过 OpenClaw 兜底`);
280
244
  }
281
245
  finalMarkedText = fin;
246
+ markerSystemActive = true;
247
+ return;
248
+ }
249
+ if (text.includes("[-process-]")) {
250
+ if (!processMarkerLogged) {
251
+ processMarkerLogged = true;
252
+ log.info(`[DingTalk][marker] 检测到 ${PROCESS_TAG}(过程段),抑制 OpenClaw 默认兜底`);
253
+ }
254
+ markerSystemActive = true;
255
+ return;
282
256
  }
283
- if (!isReasoning && text.trim()) lastAnswerText = text;
257
+ if (!markerSystemActive && !isReasoning && text.trim()) lastAnswerText = text;
284
258
  };
285
- const pickFinalText = () => finalMarkedText ?? (lastAnswerText || accumulatedText);
259
+ const pickFinalText = () => markerSystemActive ? finalMarkedText ?? accumulatedText : lastAnswerText || accumulatedText;
286
260
  const applyReplyTemplate = async (text) => {
287
261
  try {
288
262
  const runner = getGlobalHookRunner?.();
@@ -428,6 +402,7 @@ function createDingtalkReplyDispatcher(params) {
428
402
  log.info(`[DingTalk][closeStreaming] 开始关闭 AI Card...`);
429
403
  try {
430
404
  let finalText = finalClean(pickFinalText());
405
+ const hadRealAnswer = finalText.trim().length > 0;
431
406
  log.info(`[DingTalk][closeStreaming] 最终答案来源=${finalMarkedText !== null ? "marker[-final-]" : lastAnswerText ? "非reasoning答案" : "accumulatedText兜底"},长度=${finalText.length}`);
432
407
  if (!finalText.trim()) {
433
408
  finalText = "✅ 任务执行完成(无文本输出)";
@@ -479,9 +454,45 @@ function createDingtalkReplyDispatcher(params) {
479
454
  log.warn(`[DingTalk][closeStreaming] 养成系统处理失败(不影响主流程): ${gamErr?.message || gamErr}`);
480
455
  }
481
456
  finalText = await applyReplyTemplate(finalText);
482
- log.info(`[DingTalk][closeStreaming] 准备调用 finishAICard,文本长度=${finalText.length}`);
483
- log.debug(`[DingTalk][closeStreaming] 最终发送内容长度=${finalText.length}`);
484
- await finishAICard(cardSnapshot, finalText, account.config, log);
457
+ if (account.config?.answerCard !== false) {
458
+ const finalizeOriginalToDone = async () => {
459
+ try {
460
+ await streamAICard(cardSnapshot, "✅ 思考完成", true, account.config, log);
461
+ await finishAICard(cardSnapshot, "✅ 思考完成", account.config, log);
462
+ } catch (e) {
463
+ log.warn(`[DingTalk][closeStreaming] 原卡定格思考完成失败(忽略):${e?.message || e}`);
464
+ }
465
+ };
466
+ const answerTplId = (account.config?.answerCardTemplateId)?.trim() || "d246b7f5-1783-4e9b-bb46-bef52d63050e.schema";
467
+ const answerActToken = Number(account.config?.answerActToken) || 600;
468
+ const answerTokens = estimateTokens(finalText);
469
+ if (!hadRealAnswer) {
470
+ log.info(`[DingTalk][closeStreaming] answerCard 模式:无真实答案,仅定格原卡思考完成(不建答案卡)`);
471
+ await finalizeOriginalToDone();
472
+ } else if (answerTokens <= answerActToken) {
473
+ log.info(`[DingTalk][closeStreaming] answerCard 模式:答案约 ${answerTokens} token ≤ ${answerActToken},原卡直接定稿(不建答案卡)`);
474
+ try {
475
+ await streamAICard(cardSnapshot, finalText, true, account.config, log);
476
+ } catch (e) {
477
+ if (!isQpsLimitError(e)) log.warn(`[DingTalk][closeStreaming] 原卡流式覆盖最终答案失败(忽略,继续 FINISHED):${e?.message || e}`);
478
+ }
479
+ await finishAICard(cardSnapshot, finalText, account.config, log);
480
+ } else {
481
+ log.info(`[DingTalk][closeStreaming] answerCard 模式:答案约 ${answerTokens} token > ${answerActToken},原卡思考完成 + 新建答案卡(模板=${answerTplId})`);
482
+ await finalizeOriginalToDone();
483
+ const answerCard = await createAICardForTarget(account.config, target, log, answerTplId);
484
+ if (answerCard) {
485
+ await finishAICard(answerCard, finalText, account.config, log);
486
+ log.info(`[DingTalk][closeStreaming] ✅ 答案卡投放成功`);
487
+ } else {
488
+ log.warn(`[DingTalk][closeStreaming] 答案卡创建失败,降级定稿原卡`);
489
+ await finishAICard(cardSnapshot, finalText, account.config, log);
490
+ }
491
+ }
492
+ } else {
493
+ log.info(`[DingTalk][closeStreaming] 准备调用 finishAICard,文本长度=${finalText.length}`);
494
+ await finishAICard(cardSnapshot, finalText, account.config, log);
495
+ }
485
496
  log.info(`[DingTalk][closeStreaming] ✅ AI Card 关闭成功`);
486
497
  } catch (error) {
487
498
  log.error(`[DingTalk][closeStreaming] ❌ AI Card 关闭失败:${error?.message || String(error)}`);
@@ -511,14 +522,28 @@ function createDingtalkReplyDispatcher(params) {
511
522
  lastAnswerText = "";
512
523
  processMarkerLogged = false;
513
524
  finalMarkerLogged = false;
514
- if (streamingEnabled) startStreaming();
525
+ markerSystemActive = false;
515
526
  typingCallbacks.onActive?.();
516
527
  },
517
528
  deliver: async (payload, info) => {
518
529
  let text = payload.text ?? "";
519
- log.info(`[DingTalk][deliver] 被调用:kind=${info?.kind}, textLength=${text.length}, hasText=${Boolean(text.trim())}`);
520
- log.debug(`[DingTalk][deliver] payload keys=${Object.keys(payload).join(",")}, info.kind=${info?.kind}`);
521
- observeReply(payload.text, payload.isReasoning);
530
+ log.debug(`[DingTalk][deliver] kind=${info?.kind}, textLength=${text.length}`);
531
+ observeReply(payload.text, payload);
532
+ if (isNonAnswerPayload(payload)) {
533
+ if (streamingEnabled && currentCardTarget && !asyncMode && finalMarkedText === null) {
534
+ const now = Date.now();
535
+ if (now - lastUpdateTime >= updateInterval) {
536
+ lastUpdateTime = now;
537
+ try {
538
+ await streamAICard(currentCardTarget, displayClean(text), false, account.config, log, account.config?.cardContentVar || "msgContent");
539
+ } catch (e) {
540
+ if (!isQpsLimitError(e)) log.warn(`[DingTalk][deliver] 状态/错误 payload 写卡失败:${e?.message || e}`);
541
+ }
542
+ }
543
+ }
544
+ log.info(`[DingTalk][deliver] 非答案 payload(isError=${payload.isError},isStatusNotice=${payload.isStatusNotice}),仅展示不计入最终答案`);
545
+ return;
546
+ }
522
547
  if (info?.kind === "final" && text.trim()) {
523
548
  const target = isDirect ? {
524
549
  type: "user",
@@ -638,32 +663,30 @@ function createDingtalkReplyDispatcher(params) {
638
663
  replyOptions: {
639
664
  ...replyOptions,
640
665
  onModelSelected,
666
+ allowToolLifecycleWhenProgressHidden: true,
667
+ suppressDefaultToolProgressMessages: true,
641
668
  ...streamingEnabled && { onPartialReply: async (payload) => {
642
- log.info(`[DingTalk][onPartialReply] 被调用,payload.text=${payload.text ? payload.text.length : "null"}`);
643
- log.debug(`[DingTalk][onPartialReply] textLength=${payload.text?.length ?? 0}`);
644
- if (!payload.text) {
645
- log.debug(`[DingTalk][onPartialReply] 空文本,跳过`);
646
- return;
647
- }
648
- log.debug(`[DingTalk][onPartialReply] 收到部分响应,文本长度=${payload.text.length}`);
649
- observeReply(payload.text, payload.isReasoning);
669
+ if (!payload.text) return;
670
+ observeReply(payload.text, payload);
671
+ if (isNonAnswerPayload(payload)) return;
650
672
  if (asyncMode) {
651
- log.debug(`[DingTalk][onPartialReply] 异步模式,累积响应`);
652
673
  asyncModeFullResponse = finalClean(finalMarkedText ?? payload.text);
653
674
  return;
654
675
  }
676
+ if (finalMarkedText !== null) {
677
+ accumulatedText = payload.text;
678
+ return;
679
+ }
655
680
  await startStreaming();
656
681
  if (currentCardTarget) {
657
682
  accumulatedText = payload.text;
658
683
  const now = Date.now();
659
684
  if (now - lastUpdateTime >= updateInterval) {
660
685
  const { FILE_MARKER_PATTERN, VIDEO_MARKER_PATTERN, AUDIO_MARKER_PATTERN } = await import("./common-C8pYKU_y.mjs");
661
- const displayContent = displayClean(finalMarkedText ?? accumulatedText).replace(FILE_MARKER_PATTERN, "").replace(VIDEO_MARKER_PATTERN, "").replace(AUDIO_MARKER_PATTERN, "").trim();
662
- log.debug(`[DingTalk][onPartialReply] 更新 AI Card,显示文本长度=${displayContent.length}`);
686
+ const displayContent = displayClean(accumulatedText).replace(FILE_MARKER_PATTERN, "").replace(VIDEO_MARKER_PATTERN, "").replace(AUDIO_MARKER_PATTERN, "").trim();
663
687
  lastUpdateTime = now;
664
688
  try {
665
689
  await streamAICard(currentCardTarget, displayContent, false, account.config, log, account.config?.cardContentVar || "msgContent");
666
- log.debug(`[DingTalk][onPartialReply] ✅ AI Card 更新成功`);
667
690
  } catch (err) {
668
691
  if (isQpsLimitError(err)) log.warn(`[DingTalk][onPartialReply] AI Card 流式更新遇到 QPS 限流,已在内部退避重试;本次跳过,等待下一次 partial 更新补齐内容`);
669
692
  else {
@@ -671,9 +694,26 @@ function createDingtalkReplyDispatcher(params) {
671
694
  await sendFallbackErrorMessage("sendMessage", err.message);
672
695
  }
673
696
  }
674
- } else log.debug(`[DingTalk][onPartialReply] 节流控制,跳过本次更新(距离上次更新 ${now - lastUpdateTime}ms)`);
675
- } else log.warn(`[DingTalk][onPartialReply] ⚠️ AI Card 不存在,跳过更新`);
697
+ }
698
+ }
676
699
  } },
700
+ onToolStart: async (payload) => {
701
+ const toolName = (payload?.name || "").trim();
702
+ if (!toolName) return;
703
+ if (payload?.phase === "end" || payload?.phase === "complete") return;
704
+ if (!streamingEnabled || asyncMode || finalMarkedText !== null) return;
705
+ try {
706
+ await startStreaming();
707
+ if (!currentCardTarget) return;
708
+ const now = Date.now();
709
+ if (now - lastUpdateTime < updateInterval) return;
710
+ lastUpdateTime = now;
711
+ await streamAICard(currentCardTarget, `🔧 正在调用工具:${toolName}`, false, account.config, log, account.config?.cardContentVar || "msgContent");
712
+ log.debug(`[DingTalk][onToolStart] 工具进度写卡:${toolName}(phase=${payload?.phase})`);
713
+ } catch (e) {
714
+ if (!isQpsLimitError(e)) log.warn(`[DingTalk][onToolStart] 工具进度写卡失败:${e?.message || e}`);
715
+ }
716
+ },
677
717
  onCommandOutput: (payload) => {
678
718
  const dwsMatch = (payload.title || payload.name || "").match(DWS_PRODUCT_PATTERN) || payload.output?.match(DWS_PRODUCT_PATTERN);
679
719
  if (dwsMatch) {