v3.8.1 发布说明
本次 patch 版本包含两项修复:
- AI Card 长回复渲染为空白卡片(
Issue #615):卡片模式下最终回复超过约 3000 个中文字符时,钉钉接口返回成功但客户端渲染为空白卡片。本版本按 Unicode 码点引入两级分片保护(单卡多 block + 多卡兜底),并把分片策略统一到src/shared/message-chunker.ts,同时补齐此前没有长度保护的主动消息链路。 - markdown 模式长回复的多条消息显示顺序错乱(
Issue #626,修复见PR #627):钉钉不能编辑已发消息,流式回复只能按增量逐条追加,同一秒内到达的多条消息在客户端可能被显示成乱序(实测 6 条出现1,2,3,5,4,6)。本版本新增outboundSendIntervalMs会话级发送间隔,默认1000,把连续发送摊到不同秒。
同时修正 ClawHub 发布 workflow 的"假绿"问题:v3.8.0 发布时该 job 在版本实际可解析前约 230 秒就已报成功。
Important
本版本新增一个配置项 channels.dingtalk.outboundSendIntervalMs 并默认启用 1000(毫秒)。
这是行为变更:同一会话内长度不足以装进一条消息的回复,现在会按约 1 秒间隔分多条发出(6 条回复约慢 5 秒)。
这一代价换来的是显示顺序正确;若你更在意延迟、可以接受偶发乱序,把它设为 0 即完全恢复 v3.8.0 的发送行为。
最低宿主版本保持 OpenClaw 2026.8.1 不变,除该键外无其它配置迁移。
最新版本入口:latest.md
⚙️ 配置项变更
| 配置项 | v3.8.0 | v3.8.1 | 说明 |
|---|---|---|---|
outboundSendIntervalMs
| 不存在 | 1000
| 同一会话内两次出站消息之间的最小间隔(毫秒),范围 0–10000;0 表示不节流
|
- 作用范围限于同一会话:不同会话/不同账号互不影响;每个 HTTP 消息消耗一个时间槽,因此"多次调用"(流式增量尾巴)与"一次调用内多分片"(超过 3800 码点的拆分)都会被覆盖,且不会叠加成双倍间隔。
- 首次发送立即发出,不等待;后续消息只补齐"距上一槽的剩余时间",不会硬等一整格(实测等待
814–944 ms,差额即上一次 API 调用耗时)。 - 不影响卡片流式更新:AI Card 的
PUT /v1.0/card/streaming不经过节流路径,卡片刷新节奏仍由cardStreamInterval控制。 - 多账号下按账号 + 会话隔离,可用账号级配置覆盖渠道级默认值。
- 配置示例:
🛠 修复
markdown 模式多条消息显示顺序错乱
-
问题现象(
Issue #626,由本轮 v3.8.1 真机验证发现并归档)messageType: "markdown"下,钉钉无法编辑已发消息,插件按reply-strategy-markdown.ts的computeIncrementalSuffix()把流式回复切成增量尾巴逐条追加。- 增量较多时多条消息在同一秒内连续发出,钉钉客户端对它们的排序不稳定:实测 6 条消息显示为
1,2,3,5,4,6(第 4/5 条互换)。内容不丢失、不重复,但阅读顺序被打断。 - 该缺陷先于本版本存在,不是
Issue #615修复引入的;本版本一并修好。
-
根因定位(真机对照实验,两次回复正文完全一致:6392 字符、单行、
1..1500)- 发送侧证据:日志中 6 轮
Session webhook response → QuotedRef][Persist]严格交替,每次await发完才记下一个引用,answer prefix drift告警为 0 → 发送有序、内容无损,且computeIncrementalSuffix()只在current.startsWith(prev)时输出差值,构造上必然有序。 - 6 条不是分片器产生的:分片器(上限 3800)对该 6392 字符单行正文只产出 2 条;6 条的条数由宿主吐出的 block 数决定。
- 对照结果:无节流时 6 条全部落在同一秒 → 显示乱序;启用
1000ms后摊到 6 个不同秒 → 显示顺序恢复正常。 - 结论:根因是同一秒内多条消息的时间戳打平。
- 发送侧证据:日志中 6 轮
-
- 新增
src/shared/outbound-throttle.ts:会话级出站闸门runThrottledOutboundSend()。同一会话的连续发送按outboundSendIntervalMs间隔,并串行通过该会话的发送链——下一条只在上一条 settle 之后才被准入。 - 为什么不只做"间隔":客户端看到的是完成顺序,不是准入顺序。只错开开始时间的话,慢的发送仍会与下一条重叠并可能后完成,等于没修。因此闸门接管的是发送本身,间隔同时从"上一条开始"起算:快速发送保持 ~1s 节奏,慢速发送顺延而非重叠。
- 单条发送失败只影响它自己的调用方,链不会中断——一次失败不会卡死该会话后续所有发送。
- 作用域按账号 + 会话隔离:不同账号即使复用同一
conversationId也不会互相等待;拿不到会话标识时直接跳过节流,避免把不相关的会话串到同一个闸门上。 - 接入出站边界而非各调用方:
sendBySession()分片循环、sendProactiveTextOrMarkdown()分片循环、sendProactiveCardText()(因此同时覆盖sendSplitProactiveCards()的多卡兜底)。每个 HTTP 消息消耗一次准入,所以"多次调用"(流式增量尾巴)与"一次调用内多分片"(>3800 码点)都被覆盖,且不会叠加成双倍间隔。 - 每次真正等待都会输出可核对日志:
[DingTalk] Outbound send throttled scope=… waitMs=… intervalMs=…。
- 新增
-
已知边界
1000ms是保守实测值,不是最小有效间隔:只验证了 1000ms 有效,没有做下界二分。"必须 ≥1000ms"是与秒级时间戳粒度一致的推断。- 该缺陷本身是概率性的(首次也只错了一对相邻消息),真机验证为单次通过。
- 多卡兜底与主动消息分片路径的节流已接入并有单元测试覆盖,但未做真机故障注入验证。
- 真机验证覆盖的是顺序发送路径:真实回复链路按顺序
await每次发送,因此真机不会触发并发场景。并发下的顺序与间隔由单元测试覆盖(含"慢发送不可被后发超越""同一 tick 三条排队仍按间隔"),未做真机并发验证。
AI Card 长回复不再渲染为空白卡片
-
问题现象(
Issue #615,by @jznrhnn)messageType: "card"模式下,最终回复超过约 3000 个中文字符时,钉钉客户端显示空白消息卡片;较短回复正常。- 最终提交链路把完整回答放进单个
{ type: 0, markdown: content }block 并通过blockList一次性提交,代码中没有单 block 长度校验、分块或超限降级。 - 普通 Markdown 消息虽有约 3800 字符的分片逻辑,但该逻辑没有应用到 AI Card block。
- 属于"接口成功、客户端不渲染"的静默失败:插件侧无法从 HTTP status 判断内容是否真的渲染成功。
-
单卡多 block 分片(
PR #624,by @soimy)- 新增
splitCardBlocks():blockList中任何超过CARD_BLOCK_CHUNK_LIMIT(2500 码点,取值保守地低于实测约 3000 中文字符的空白渲染阈值)的 markdown block 会被拆成多个 block 一次提交,非 markdown 字段按片保留。 - 在两个 blockList 产出点统一应用:
renderTimelineAsBlocks()(经由queueRender(),覆盖 finalize、/stop与流式 blockList 更新三条路径)与sendProactiveCardText()。 - 超长回归用例确认:超限回答渲染为多个有界 block,内容按序完整保留。
- 新增
-
多卡兜底:卡片失败时优先补发新卡,再降级 Markdown
- 新增
sendSplitProactiveCards():用同一套共享分片器按CARD_BLOCK_CHUNK_LIMIT(2500 码点)切分长文本,每片一张新 AI Card 顺序投递,保持阅读顺序。页码加在 statusLine 头部,不占用正文长度预算。 - 页码
page(n/m)追加到原 statusLine 头部而不是替换它,所以模型名、effort 等任务元数据仍然可见。 reply-strategy-card的 finalize 分支:卡片为FAILED时优先走拆多卡,只有在拿不到conversationId或新卡也无法创建时才降级为 Markdown;commitAICardBlocks()抛错的 finalize catch 分支同样先用多卡救援(并防止对已FINISHED的卡片重复救援)。
- 新增
-
不丢内容、不重复投递
sendSplitProactiveCards()返回unsentChunks,调用方只补发确实没有投递出去的部分(零片成功时补发全文),避免"接口半成功"导致答案被静默截断。- 未投递分片逐片单独重发,不做拼接:用任何分隔符重新拼接都会引入原文中不存在的字符(例如无换行的超长 URL、压缩过的单行文本)。
- 已部分投递(
sent > 0)时不再整段重发 Markdown,避免用户收到重复内容。
-
内容提取保持无损
getRenderedContent()改为读取未分片的原始 timeline;展示层分片(splitCardBlocks)只作用于 blockList 产出点(getRenderedBlocks()/queueRender())。因此content与copy_content重建出来的回答不会出现伪造的\n\n分隔符或被重开的代码围栏。
-
分片器保真规则(
src/shared/message-chunker.ts)- 按 Unicode 码点计量,不会切断 emoji / 代理对。
- 代码围栏感知:跨片时自动闭合并在下一片重开围栏;重开的围栏后必定补换行,内容不会落到围栏 info-string 行上而停止渲染。
- 无换行超长单行(超长 URL、压缩单行)先按码点硬切,硬切产生的续片重新接回时不插入换行,不伪造原文没有的断行。
- 会话 webhook 与主动消息走同一套策略,
MESSAGE_CHUNK_LIMIT维持 3800 码点不变。
-
主动消息链路补上长度保护
- 主动发送(
oToMessages/groupMessages)此前把无界内容塞进单个sampleMarkdown/sampleText请求,现在同样经由共享分片器切分。
- 主动发送(
-
文档更新:
docs/user/features/ai-card.md新增「长回复分片」章节,说明两级分片、页码展示与降级顺序。
已知边界
CARD_BLOCK_CHUNK_LIMIT(2500)与MESSAGE_CHUNK_LIMIT(3800)是经验阈值:空白渲染边界为实测约 3000 中文字符,钉钉侧未公开该限制,因此取值偏保守而非精确。- 单行内部包含
```的超长行仍可能在硬切时切断围栏标记,导致围栏状态漂移;当前判定为可接受的已知折衷,在确认真实影响前不做处理。 - 分片只保证内容完整投递,不改变卡片整体形态:一次超长回复可能呈现为多张卡片。
🧩 工程与 CI
-
ClawHub 发布不再"假绿"(commit
48224c7,by @soimy)- 背景:v3.8.0 发布暴露了该问题——
clawhub package publish --json在 ClawHub 刚把发布排进队列时即返回status=pending-publication/publicationStatus=pending,步骤随即打印"ClawHub accepted the release"并报绿,而GET /versions/3.8.0约 230 秒后才停止返回 404。在审计不再阻断发布之后,"run 绿了"就更不能作为"已经发出去"的证据。 clawhub-publish.yml的发布调用加上--wait --wait-timeout 2400,让退出码承载发布判定(blocked/failed/expired/ 超时均为非 0)。- job
timeout-minutes由 30 提到 50:否则等待会先被 job 超时掐死,等于让该修复变成空操作。 - 额外断言捕获到的 JSON 中终态
publicationStatus === 'published',防止将来 CLI 改动丢掉--wait语义后静默退回"发了就不管"。 - 测试把该接线与超时关系固定住:job 超时小于等于
--wait-timeout会直接让测试失败。
- 背景:v3.8.0 发布暴露了该问题——
-
测试:本区间新增分片器、卡片分块、多卡兜底(含零片救援、部分分片补发、无换行硬切保真、围栏重开换行)与无损内容提取的回归用例;
src/shared/message-chunker.ts语句覆盖率 95.6%。已知待办Issue #622(拆分超过 800 行的测试文件)仍为 open,不在本版本处理。
📋 升级检查清单
- 宿主版本 ≥
OpenClaw 2026.8.1(未变化) - 确认可接受
outboundSendIntervalMs默认1000带来的延迟:长度需分多条发出的回复现在按约 1 秒间隔发送(6 条约慢 5 秒),换来显示顺序正确 - 若长回复延迟不可接受、可接受偶发乱序:设
outboundSendIntervalMs: 0恢复 v3.8.0 的连续发送行为 - 若使用
messageType: "markdown":升级后用一条超过 3800 码点的回复确认多条消息按顺序显示 - 如在卡片模式下依赖"一次回复一张卡片"的形态,请留意超长回复现在可能拆为多张卡片(内容不丢失;卡片带
page(n/m)页码,即使显示颠倒也能看出正确页序) - 升级后建议用一条超过 3000 字的回复确认卡片正常渲染
🤝 鸣谢
- @jznrhnn(
Issue #615)—— 完整的问题反馈:给出可复现步骤、实测字符阈值、静态调用链定位与期望行为,直接决定了本版本的分片与降级设计。 - @soimy(
PR #624、commit48224c7、PR #627(Issue #626的定位与修复))
Issue #626(markdown 模式多条消息显示顺序)不是外部报告:它由本轮 v3.8.1 真机验证在 markdown 模式下发现——1..1500 的探针回复被拆成 6 条,客户端显示为 1,2,3,5,4,6。随后通过"发送侧日志严格交替 + 正文无损 + 客户端显示顺序逐条回报"确认发送有序、显示错序,再用 1000ms 节流对照实验坐实根因为同一秒内时间戳打平。相关内容一并记为已知边界:该缺陷具有概率性,验证为单次通过,且未测定最小有效间隔。
发布页面:https://github.com/soimy/openclaw-channel-dingtalk/releases/tag/v3.8.1
Full Changelog: v3.8.0...v3.8.1
{ "channels": { "dingtalk": { "messageType": "markdown", // 默认 1000;设为 0 恢复 v3.8.0 的连续发送行为 "outboundSendIntervalMs": 1000 } } }