Skip to content

feat(card): implement card template v2 with rich block content and action btns#480

Open
soimy wants to merge 43 commits intomainfrom
card-template-v2-clean
Open

feat(card): implement card template v2 with rich block content and action btns#480
soimy wants to merge 43 commits intomainfrom
card-template-v2-clean

Conversation

@soimy
Copy link
Copy Markdown
Owner

@soimy soimy commented Apr 1, 2026

背景

当前 AI Card 使用单一 content 变量承载 markdown 文本进行流式渲染,无法区分 thinking/tool/answer 等不同语义的内容块,模板无法针对不同块类型做差异化渲染。本次重新设计了新一代卡片模板,支持 blockList 结构化数据契约,允许每个块携带 type 字段驱动条件渲染。

目标

  • 使用 blockList 替代单一 content 字段承载流式内容
  • 定义 CardBlock 类型系统,包含 text/markdown/type/mediaId/btns 字段
  • 实现 session-level taskInfo 追踪(model/effort/dap_usage/taskTime),在卡片页脚展示任务元数据
  • 统一使用 v2 模板,移除 legacy 模板分支,简化代码

实现

数据契约

  • 新增 CardBlock/CardBlockType/CardBtn/CardTaskInfo 类型定义(src/types.ts
  • CardBlockType: 0=answer, 1=think, 2=tool, 3=image, 4=approval, 5=topic

模块变更

模块 变更
src/card/card-template.ts 统一 v2 模板,移除 isV2,保留 DINGTALK_CARD_TEMPLATE_ID 作为运维兜底 override
src/card-draft-controller.ts renderTimeline() 始终输出 CardBlock[] JSON;新增 PROCESS_BLOCK_TEXT_MAX_LENGTH 截断常量;新增 appendStopMarkerBlock() 保留结构化内容
src/card-service.ts createAICard 初始化 taskInfo 变量;streamAICard finalize 前先写 content 再发 isFinalize=true;新增 streamTaskInfo();新增 incrementCardDapiCount()/getCardTaskTimeSeconds()
src/session-state.ts 简化为 session-level 共享状态,仅存储 model/effort;移除 dapiCount/taskStartTime
src/types.ts AICardInstance 新增 taskInfo 字段(per-card metrics)
src/reply-strategy.ts 新增 ModelSelectedContext 接口和 onModelSelected 回调
src/reply-strategy-card.ts 实现 onModelSelected:model/effort 变化时同步到所有活跃卡片
src/card/card-run-registry.ts 新增 listActiveCardsByConversation() 用于多卡片同步
src/inbound-handler.ts initSessionState(),不再清除已存在的 session state

Finalize 顺序

修复了 Codex 对抗性审核发现的 P1 问题,调整 finalize 调用顺序:

  1. 先写 content 字段(复制按钮用,isFinalize=false
  2. 再发 blockListisFinalize=true

确保 DingTalk API 在关闭流后复制按钮仍能正常工作。


Codex 对抗性审核修复

基于 Codex adversarial review(/codex:adversarial-review --base main)发现的问题进行了以下修复:

P0: Pending-card recovery rollback safety

问题:持久化记录不包含模板/schema 版本标识,服务重启或 rollback 时可能用错误的字段契约恢复卡片,导致失败后删除记录丢失恢复线索。

修复

  • PendingCardRecord 新增 lastContent 字段存储完整的 CardBlock[] JSON
  • 恢复时使用 lastContent 而非 fallback 文本
  • Legacy 记录(无 lastContent)直接删除,不尝试恢复
  • 恢复失败时保留记录以供手动排查

P1: Stop 内容丢失

问题:停止时将 CardBlock[] 扁平化为纯文本,丢失 mediaId/btns 等结构化内容。

修复

  • 新增 appendStopMarkerBlock() 函数
  • 保留现有 CardBlock[] 结构,仅追加停止标记块
  • 所有图片、按钮等结构化元素都被保留

P2: TaskInfo 跨 run 状态污染

问题dap_usage/taskTimeaccountId:conversationId 存储,多 run 场景下状态混乱。

修复

  • dap_usage/taskTime 移入 AICardInstance.taskInfo(per-card 隔离)
  • SessionState 仅保留 model/effort(session-level 共享)
  • onModelSelected 回调同步到所有活跃卡片
  • incrementCardDapiCount(card) 累加特定卡片的计数
  • getCardTaskTimeSeconds(card) 计算特定卡片耗时

实现 TODO

  • CardBlock/CardBlockType/CardBtn/CardTaskInfo 类型定义
  • card-template.ts 统一 v2 模板,移除 legacy 分支
  • card-draft-controller.ts 渲染 CardBlock[] JSON
  • card-service.ts createAICard 初始化 taskInfo 变量
  • card-service.ts streamAICard finalize 顺序调整
  • card-service.ts finishStoppedAICard finalize 顺序调整
  • session-state.ts in-memory session state 管理(简化为 model/effort)
  • reply-strategy-card.ts onModelSelected 回调实现(多卡片同步)
  • inbound-handler.ts session state 初始化(不再清除已存在状态)
  • dap_usage 计数器集成(per-card 隔离)
  • finishAICard finalize 路径推送最终 taskInfo
  • PendingCardRecord.lastContent 恢复安全性
  • appendStopMarkerBlock 停止内容保留
  • AICardInstance.taskInfo per-card 隔离

验证 TODO

  • pnpm run type-check 通过
  • pnpm run lint 通过(无新增警告)
  • pnpm test — 71 文件,815 用例全部通过
  • 真机验证:卡片创建、流式渲染、finalize
  • 真机验证:thinking/tool/answer 块差异化渲染
  • 真机验证:复制按钮功能(content 字段同步)
  • 真机验证:taskInfo 页脚显示(model/effort/dap_usage/taskTime)
  • 真机验证:onModelSelected 实时推送 model/effort
  • 真机验证:停止后结构化内容保留(图片、按钮)
  • 真机验证:服务重启后 pending card 恢复
  • 真机验证:卡片失败后 markdown 回退

Breaking Changes

  • 保留 DINGTALK_CARD_TEMPLATE_ID 环境变量 override,作为模板灰度/紧急回退时的运维兜底;默认仍使用内置 v2 模板
  • 移除 legacy markdown 流式模式
  • Legacy pending cards(无 lastContent 字段)将在恢复时被删除

相关设计文档

AI 辅助声明

本 PR 由 AI 辅助完成。提交者已审查全部变更,确认类型检查和测试通过。

后续补充修复

Media 链路修复

参考 docs/plans/2026-04-14-card-v2-consecutive-dm-image-sessionkey-minus-handoff.md,这次补上了单聊连续发图场景下 card run 归属丢失的问题。根因是 OpenClaw runtime 在 message tool 的 handleAction 阶段会传 sessionKey=-,导致 channel 侧无法稳定从 sessionKey 还原 conversationId,同时 runtime 也不会自动补 expectedCardOwnerId,于是图片发送时无法命中当前活跃 card run,最终退化成独立消息。

这部分修复主要包括:

  • src/card/card-run-registry.ts 新增 resolveCardRunByOwner,允许仅按 accountId + ownerUserId 回查最近活跃 card run。
  • src/send-service.ts 调整图片命中策略:优先 conversation + owner,其次 conversation only,最后在 conversationId 缺失但 expectedCardOwnerId 可用时走 owner-only fallback
  • src/channel.ts 的 message tool action 层不再只依赖 runtime 透传的 sessionKey,而是在 direct target 场景下从当前 target 回推出 conversationIdexpectedCardOwnerId,使 sessionKey=- 时仍然能把图片嵌回当前用户的活跃 card。

这让单聊连续两次请求发图时,第二次图片也能继续走 card 内嵌链路,而不是掉到 card 外的主动消息。

dapi_usage 修复

这部分前后收敛了两层问题:

  1. 数值被旧缓存“卡住”

此前 taskInfo 在早期刷新后会把 ctx.taskMeta.usage 固定成旧值,导致 finalize 时即使卡片上的 dapi_usage 已增长,最终写回卡片页脚的仍然是旧值,表现为一直停在较小的数字。现在 finalize 生成 taskInfoJson 时会优先读取最新的 card.taskInfo.dapi_usage,避免被旧的 ctx.taskMeta.usage 缓存覆盖。

  1. 数值被过度调用放大

实际排查中又发现 answer 模式下有两个额外放大来源:

  • content 流更新绕过了统一的 cardStreamInterval 节流。
  • answer 模式的 partial reply 一边走 streaming content,一边又每次都触发 updateAICardBlockList,导致本来只该在 think/tool/assistant-turn/finalize 边界刷新的 blockList 被按 partial 频率重复刷新。

这次的修复是:

  • src/card-draft-controller.ts 中把 contentblockList 拆成两套独立 loop,content 现在走独立的 latest-wins 节流发送,真正受 cardStreamInterval 控制。
  • answer 模式下,partial answer 只实时更新 content,不再每次都实时更新 blockListblockList 只在 think/tool/assistant-turn/finalize 这些边界统一 flush。
  • finalize 阶段去掉了“先 clear 一次 streaming content,再 replay 一次最终 answer”的额外调用,最终只保留一次 card/instances 收口提交。

这样一来,dapi_usage 不仅不会再被旧缓存卡住,也不会因为 answer 模式下的重复 content/blockList 刷新而被明显放大。

Review follow-up 修复(2026-04-14, a168a6f

  • 修正 taskTime 计算,按 card 生命周期与已有 meta/session 取最大值,避免同会话 timer reset 后污染当前卡片。
  • 补齐 card reply 媒体链路的 mediaUrlAllowlist 透传,覆盖 payload.mediaUrls 和 deferred 非图片附件。
  • 修正 streaming 在 401 刷新 token 后重试成功时的 dapi_usage 计数,成功重试也会准确累计一次。

补充验证:

  • pnpm vitest run tests/unit/reply-strategy-card.test.ts tests/unit/card-service.test.ts tests/unit/inbound-handler-card-streaming.test.ts tests/unit/message-actions.test.ts tests/unit/send-service-media-owner-fallback.test.ts tests/unit/send-service-media.test.ts
  • npm run type-check

@soimy soimy changed the title feat(card): implement blockList v2 template with structured card rendering and taskInfo streaming feat(card): implement card template v2 with rich block content and action btns Apr 1, 2026
@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 2, 2026

第二轮 Codex Adversarial Review 修复进展

基于第二轮 Codex adversarial review 发现的 3 个问题,已全部修复并推送(673ec4b)。

修复内容

1. lastContent 周期性持久化 (HIGH)

问题lastContent 仅在卡片创建时写入 "[]",流式传输期间从未更新。重启后记录看起来像 legacy 格式,被直接删除。

修复

  • draft-stream-loop 中添加 onEveryNSends 回调 + persistInterval 参数
  • 新增 updatePendingCardLastContent() 函数,仅更新 lastContent 字段(不重写完整记录)
  • 5 次成功发送后触发一次持久化,平衡 I/O 开销与数据安全

2. Recovery 中断消息显示 (MEDIUM)

问题finalizePendingCardsByAccount 收到 reason 参数但从未使用,恢复后用户看不到任何中断提示。

修复

  • 新增 appendRecoveryMarkerBlock(content, reason) 函数
  • 恢复时在 CardBlock[] 末尾追加含中断原因的标记块(与 stop marker 风格一致)
  • 用户现在能看到 "⚠️ 上一次回复处理中断,已自动结束。请重新发送你的问题。" 等提示

3. taskInfo 发送顺序 + 移除 hideCardStopButton (MEDIUM)

问题finishAICard 先调用 streamAICard(..., true) 关闭 stream,再发送 final taskInfo。DingTalk API 可能拒绝 isFinalize=true 后的写入。

修复

  • 调整 finishAICard 顺序:taskInfo → copyKey → blockList finalize
  • 移除 hideCardStopButton 调用(v2 模板完成态天然无停止按钮)
  • 同步移除 card-stop-handler.ts 中的 Phase 3(hide stop button)

测试状态

全部 815 tests passing,0 lint errors。

变更文件

文件 变更类型
src/draft-stream-loop.ts 新增 onEveryNSends / persistInterval 参数
src/card-draft-controller.ts 新增 appendRecoveryMarkerBlock,注入持久化回调
src/card-service.ts 新增 updatePendingCardLastContent,重排 finishAICard 顺序,移除 hideButton 调用
src/card/card-stop-handler.ts 移除 Phase 3 hideCardStopButton
4 个测试文件 更新 PUT 次数断言、mock 补充、recovery marker 断言

@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 3, 2026

🔄 卡片生命流程更新方式 — 测试验证总结

基于对 v2 模板 (`9a9138ed-35d3-498e-9019-748b59ddc39a.schema`) 的测试,验证了卡片生命流程的正确更新方式:

生命周期 API 对照

阶段 API 方法 用途
CREATE `/v1.0/card/instances/createAndDeliver` POST 创建并投递卡片
UPDATE `/v1.0/card/instances` PUT 更新 blockList、taskInfo 等
STREAM `/v1.0/card/streaming` PUT 仅支持简单 string 类型变量(如 `content`)
FINALIZE `/v1.0/card/instances` PUT 设置最终内容 + `flowStatus=completed`

关键发现

  1. Streaming API 兼容性取决于变量类型

    • `content`(string 类型)→ streaming API ✅
    • `blockList`(loopArray 类型)→ streaming API ❌ 返回 500,需用 `/v1.0/card/instances` 替代
  2. 变量必须创建时初始化:所有 cardParamMap 变量需在创建时提供初始值,否则模板渲染为骨架/空白

  3. cardParamMap 值必须是字符串:复杂对象需要 `JSON.stringify()`

测试脚本

  • `scripts/test-full-lifecycle.ts` — 完整 5 步生命周期测试(带步骤标签)
  • `scripts/test-stream-content.ts` — streaming API 单独验证
  • `scripts/test-card-lifecycle-v2.ts` — 使用仓库函数的集成测试

详细实现指南见 `docs/plans/2026-04-03-card-v2-implementation-handoff.md`

@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 3, 2026

AI Card v2 混合流式更新 - 实现进展

✅ 已完成 (2026-04-04)

Commit: b1163d0 8c45f61

实现了混合 API 路由策略,解决了 streaming API 对复杂 loopArray 类型返回 500 错误的问题:

架构设计

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  TimelineEntry  │ ──► │   queueRender()  │ ──► │   Dual-Path     │
│  (answer/text)  │     │                  │     │   Output        │
└─────────────────┘     └──────────────────┘     └─────────────────┘
                                                         │
                        ┌────────────────────────────────┴────────────────────────────────┐
                        │                                                                         │
                        ▼                                                                         ▼
            ┌───────────────────────┐                                           ┌───────────────────────┐
            │    streaming API      │                                           │    instances API      │
            │    (content key)      │                                           │    (blockList key)    │
            │    实时预览            │                                           │    持久化存储          │
            └───────────────────────┘                                           └───────────────────────┘

关键变更

文件 变更
src/card/card-template.ts 更新模板 ID,新增 `blockListKey`/`streamingKey` 字段
src/card-service.ts 新增 `updateAICardBlockList`、`streamAICardContent`、`clearAICardStreamingContent`、`commitAICardBlocks`
src/card-draft-controller.ts 新增 `realTimeStreamEnabled` 参数,双路径输出逻辑
src/reply-strategy-card.ts 传递 `cardRealTimeStream` 配置,finalize 时清空 streaming content

配置开关行为

  • `cardRealTimeStream=true`: answer 文本流式更新到 `content` key 实时显示,边界处清空并提交到 `blockList`
  • `cardRealTimeStream=false`: 所有更新直接提交到 `blockList` via instances API

测试状态

  • 825/826 测试通过(1 个临时调试测试文件未纳入)
  • Type-check 通过
  • Lint 0 errors

参考文档

  • 实现计划: `docs/plans/2026-04-04-dingtalk-card-v2-hybrid-streaming-implementation.md`
  • Handoff: `docs/plans/2026-04-04-card-v2-hybrid-streaming-handoff.md`

下一步: 真机测试验证两种模式的卡片渲染效果

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openclaw-channel-dingtalk Ready Ready Preview, Comment Apr 5, 2026 3:22pm

@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 4, 2026

📋 Image Block 测试迁移完成

进度更新

测试状态: ✅ 829/829 全部通过

新增 Commits

Commit 描述
8126716 feat(card): add CardBlock type for AI Card v2 blockList
5554cdf refactor(card): migrate draft controller from markdown to JSON CardBlock[]
d0265af feat(card): add hasQuote/quoteContent to AI Card creation
21c9c5b feat(card): embed media as image blocks in card instead of separate messages
0558b41 test(card): update unit tests for JSON CardBlock[] format
259c298 test(card): update inbound-handler tests for CardBlock[] and image blocks
22516ac docs(card): add image block test migration handoff document

核心变更

格式迁移: 从 markdown (> 前缀) 到 JSON CardBlock[]

// 旧格式
> 思考中...
> 工具调用...
最终答案

// 新格式
[
  {"type": 1, "markdown": "思考中..."},
  {"type": 2, "markdown": "工具调用..."},
  {"type": 0, "markdown": "最终答案"},
  {"type": 3, "mediaId": "xxx"}
]

Media 处理变更:

  • 旧: mediaUrls → sendMessage (独立消息)
  • 新: mediaUrls → uploadMediaappendImageBlock (嵌入 card)

下一步

  • 真机验证 image block 渲染
  • 确认 mediaId 上传后图片正常显示

@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 4, 2026

📦 资源文件已添加

新增两个 JSON 资源文件用于 Card v2 模板:

  • card-template-v2.json: AI Card v2 模板 schema
  • card-data-mock-v2.json: 用于测试和预览 mock 卡片数据

这些资源可用于理解 v2 模板结构和进行测试数据准备。

@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 4, 2026

🔧 V2 Card Template 修复完成

修复清单

# 问题 优先级 状态
1 getRenderedContent() 返回 JSON 而非 markdown P1
2 finishAICard() 使用 streaming API finalize P0
3 远程 media URL 处理 + 非图片附件降级 P2
4 Quote header 内容来源错误 P3

核心变更

Finalize 链路重构

  • streaming API 切换到 instances API
  • 单次调用固化 blockList + content + quoteContent + flowStatus

Controller 方法拆分

  • getRenderedBlocks()CardBlock[] JSON (供 instances API)
  • getRenderedContent() → 纯 markdown (供复制/fallback)

Media 处理增强

  • 远程 URL 通过 prepareMediaInput() 下载
  • 非图片附件跳过卡片嵌入

验证结果

  • ✅ 831 tests passed
  • ✅ type-check passed
  • ✅ lint passed (0 errors)

文档

实施记录已归档至 docs/artifacts/2026-04-04-v2-card-fix-implementation.md


🤖 Generated with Claude Code

soimy added 2 commits April 6, 2026 16:54
…askInfo display

Migrate AI Card from markdown streaming to JSON CardBlock[] rendering via
DingTalk instances API. Key changes:

- CardDraftController: timeline entries rendered as CardBlock[] instead of
  markdown; supports thinking/answer/image block kinds
- commitAICardBlocks: single instances API call for finalize (replaces
  finishAICard + streamAICard dual-path)
- Inline media: images uploaded via prepareMediaInput and embedded as
  image blocks in card timeline
- quoteContent: shows inbound message text in card header
- taskInfo: displays model/usage/elapsed metadata in card footer
- Card run registry: bridges sendMedia mediaId to active card controller
- Add missing card-service mocks (updateAICardBlockList, streamAICardContent, clearAICardStreamingContent)
- Update test expectations for V2 finalize path (commitAICardBlocks instead of finishAICard)
- Fix reasoning streaming assertions to match cardStreamingMode behavior
- Add streamingKey/blockListKey to DingTalkCardTemplateContract
- Fix missing closing brace in card-draft-controller test
- Update handoff document with completed status

All 870 tests now pass.
@soimy soimy force-pushed the card-template-v2-clean branch from 692a032 to 076cbb1 Compare April 6, 2026 11:57
@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 6, 2026

🎉 Card V2 Rebase & Test Fix 完成

进度总结

Rebase PR #494 已完成,所有测试均已通过:

Test Files  75 passed (75)
Tests       870 passed (870)
Type-check  passed

修复内容

提交 说明
c161e6e feat(card): AI Card v2 — block-based rendering, media embeds, quote/taskInfo display
076cbb1 fix(test): repair 21 test failures after PR#494 rebase
470b4cb docs: update handoff with completed status and test fix details

测试修复详情

  1. card-service mock 缺失导出 — 补充 updateAICardBlockList, streamAICardContent, clearAICardStreamingContent 三个 mock
  2. V2 finalize 路径适配 — 将 finishAICardMock 期望改为 commitAICardBlocksMock
  3. cardStreamingMode 模式感知 — 更新 reasoning streaming 断言以匹配 off/answer/all 行为

冲突解决

8 个冲突文件全部已解决,详细记录在 docs/plans/2026-04-06-card-v2-rebase-handoff.md

后续工作

  • 测试文件拆分(inbound-handler.test.ts 7772 行)已评估,可作为后续优化
  • 已知问题列表见 handoff 文档

分支已准备好进行 review 或 merge。

soimy added 5 commits April 6, 2026 21:29
Design for splitting 7770-line test file into ~10 domain-focused files
with shared mock fixtures and redundancy cleanup. Includes documentation
update plan for test file scale control guidelines.
19 tasks covering mock factory, 11 split files, and 6 doc updates
Create a reusable mock factory module that will be imported by all
split test files during the inbound-handler.test.ts refactor.
Ensure local/private markdown images are rerouted into card image blocks with inline placeholders, while task metadata and session timing stay current through card creation, streaming, and finalize.
@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 14, 2026

补充说明:最近围绕卡片 V2 又收敛了两类和真机场景强相关的修复,分别是 media 链路以及 dapi_usage 统计。

1. media 链路修复

参考 docs/plans/2026-04-14-card-v2-consecutive-dm-image-sessionkey-minus-handoff.md,这次主要解决的是单聊里连续两次请求发图时,第二次图片掉出 card、变成独立消息的问题。根因是 OpenClaw runtime 在 message tool 的 handleAction 阶段会传 sessionKey=-,导致 channel 侧无法稳定从 sessionKey 还原 conversationId,同时 runtime 也不会自动补 expectedCardOwnerId,于是 sendMedia 无法命中当前活跃 card run。

这部分修复做了三层兜底:

  • src/card/card-run-registry.ts 增加 resolveCardRunByOwner,支持仅按 accountId + ownerUserId 回查最近活跃 card run。
  • src/send-service.ts 调整发图命中策略:优先 conversation + owner,其次 conversation only,最后在 conversationId 缺失但 expectedCardOwnerId 可用时走 owner-only fallback
  • src/channel.ts 的 message tool action 层,不再只依赖 runtime 传入的 sessionKey,而是在 direct target 场景下从当前 target 反推出 conversationIdexpectedCardOwnerId,让 sessionKey=- 时也能继续把图片嵌回当前用户的活跃 card。

这意味着单聊连续发图场景下,message tool 不再因为 sessionKey=- 丢失 card 归属关系,media 可以继续走 card 内嵌链路,而不是退化成独立主动消息。

2. dapi_usage 修复

这部分前后其实修了两层问题:

第一层是数值“卡住”的问题。此前 taskInfo 在早期刷新后会把 ctx.taskMeta.usage 固定成旧值,导致 finalize 时即使 card 上的 dapi_usage 已经增长,最终写回卡片页脚的仍然是旧值,表现出来就是一直停在 2。现在 finalize 生成 taskInfoJson 时会优先读取最新的 card.taskInfo.dapi_usage,避免被旧的 ctx.taskMeta.usage 缓存住。

第二层是数值“偏大”的问题。实际排查里发现 answer 模式下有两个过度调用来源:

  • content 流更新实际上绕过了 cardStreamInterval,没有统一受 1s 节流控制。
  • answer 模式的 partial reply 会一边走 streaming content,一边每次都触发 updateAICardBlockList,导致本来只该在 think/tool/answer 边界刷新的 blockList 被按 token/partial 频率不断刷新。

这次的修复是:

  • src/card-draft-controller.ts 里把 contentblockList 拆成两套独立 loop。content 现在走单独的 latest-wins 节流发送,真正受 cardStreamInterval 控制。
  • answer 模式下,partial answer 只实时更新 content,不再每次都实时更新 blockListblockList 只在 think/tool/assistant-turn/finalize 这些边界统一 flush。
  • finalize 阶段去掉了“先 clear 一次 streaming content,再 replay 一次最终 answer”的额外调用,最终只保留一次 card/instances 收口提交。

这样一来,dapi_usage 不仅不会再被旧缓存卡住,也不会因为 answer 模式下的重复 content/blockList 刷新而被明显放大。

3. 当前状态

相关修复已经补了类型检查和单测回归,重点覆盖了:

  • 单聊 direct target / sessionKey=- 的 media owner fallback
  • dapi_usage finalize 取最新值
  • answer 模式下 content 节流
  • answer 模式下 partial 不再刷 blockList
  • finalize 不再 clear streaming content / replay final answer

后续仍建议继续做真机验证,尤其关注:

  • 单聊连续两次发图是否都能嵌入 card
  • answer 模式下一条长回复的 dapi_usage 是否明显回落到符合预期的区间
  • think/tool/answer 边界是否仍然能正确落到最终 blockList

@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 14, 2026

补充一轮 review follow-up 修复,已推送 commit a168a6f

  • 修正 card taskTime 计算,优先保证按 card 生命周期统计,不再被同会话 session timer 重置污染。
  • 补齐 card reply 媒体链路里的 mediaUrlAllowlist 透传,覆盖 payload.mediaUrls 和 deferred 非图片附件。
  • 修正 card streaming 在 401 刷新 token 后重试成功时的 dapi_usage 计数,成功重试仍会准确累计一次调用。

验证:

  • pnpm vitest run tests/unit/reply-strategy-card.test.ts tests/unit/card-service.test.ts tests/unit/inbound-handler-card-streaming.test.ts tests/unit/message-actions.test.ts tests/unit/send-service-media-owner-fallback.test.ts tests/unit/send-service-media.test.ts
  • npm run type-check

@soimy soimy marked this pull request as ready for review April 14, 2026 14:20
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 14, 2026

Greptile Summary

本 PR 将卡片渲染从单一 content 字段迁移到结构化 blockList(CardBlock 数组),支持 thinking/tool/answer/image 差异化渲染,并引入 per-card taskInfo(model/effort/dapi_usage/taskTime)页脚追踪。同时修复了之前审查发现的 P1 问题(stop 路径混用 JSON 与 markdown)以及媒体链路 sessionKey 为连字符时导致图片无法嵌入卡片的问题。

  • finalizePendingCardsByAccount 在恢复失败时仍调用 removePendingCardById,与 PR 描述"恢复失败时保留记录以供手动排查"不符;Legacy 记录(无 lastContent 字段)也未按描述直接删除,而是走相同恢复路径渲染空白停止卡片。

Confidence Score: 5/5

主要用户路径无阻断性问题,815 个测试全部通过,可合并。

之前报告的 P0/P1 问题均已修复;剩余两处 P2 问题仅影响服务重启后 pending card 恢复的运维可观测性,不影响正常流程。

src/card-service.ts 中的 finalizePendingCardsByAccount 恢复失败处理逻辑需与 PR 描述保持一致。

Important Files Changed

Filename Overview
src/card-service.ts 核心卡片服务重构:新增 updateAICardBlockList/streamAICardContent/commitAICardBlocks/finalizeStoppedAICard;pending card 恢复路径存在两处与 PR 描述不符之处(失败时仍删除记录、Legacy 记录未直接删除)。
src/card/card-stop-handler.ts 先前 P1(blockListJson 被当作纯文本写入 content)已修复:分别取 lastContent 和 lastBlockListJson 传给 finalizeStoppedAICard 双通道处理。
src/reply-strategy-card.ts buildTaskInfoJson 优先读 card.taskInfo.dapi_usage;taskTime 取三者最大值;onModelSelected 正确更新当前卡片及 sessionState;finalize 路径单次调用 commitAICardBlocks。
src/card-draft-controller.ts content 和 blockList 拆成两个独立 draft-stream-loop;answer 模式下 partial 只更新 content,blockList 仅在边界 flush;新增 appendImageBlock/discardCurrentAnswer/getRenderedBlocks 接口。
src/card/card-run-registry.ts 新增 resolveCardRunByOwner 作为 sessionKey 不可用时的兜底查询;单用户多并发会话场景可能命中非目标卡片,但仅作最后兜底,风险可接受。
src/session-state.ts 简化为只保留 model/effort/taskStartTime;initSessionState 对已有状态只重置 taskStartTime,保留 model/effort 供跨 run 延续。
src/types.ts 新增 CardBlock 联合类型(type 0/1/2/3);AICardInstance 增加 taskInfo/lastBlockListJson 字段;相关 deprecated 注释已添加。
src/card/card-template.ts 统一 V2 模板,移除 isV2 分支;streamingKey 与 contentKey 保持相同值,保证复制按钮与 blockList 渲染互不干扰。

Sequence Diagram

sequenceDiagram
    participant IH as inbound-handler
    participant RS as reply-strategy-card
    participant CDC as card-draft-controller
    participant CS as card-service

    IH->>CS: createAICard (taskInfo初始化 dapi_usage=1)
    CS-->>IH: AICardInstance

    loop 流式内容
        IH->>RS: deliver(block/tool)
        RS->>CDC: updateAnswer / appendTool / appendThinkingBlock
        CDC->>CS: streamAICardContent (content key)
        CDC->>CS: updateAICardBlockList (blockList key)
        CS->>CS: incrementCardDapiCount
    end

    RS->>RS: onModelSelected
    RS->>CS: updateAICardTaskInfo (taskInfo key)

    IH->>RS: finalize()
    RS->>CDC: flush + waitForInFlight
    RS->>CS: commitAICardBlocks (blockList + content + taskInfo + flowStatus=3)
    CS->>CS: state = FINISHED, removePendingCard

    note over CS: 停止路径
    IH->>CS: finalizeStoppedAICard
    CS->>CS: buildStoppedCardFinalizePayload
    CS->>CS: state = STOPPED, removePendingCard
Loading

Reviews (3): Last reviewed commit: "fix(lint): add curly braces to satisfy e..." | Re-trigger Greptile

Comment thread src/reply-strategy-card.ts Outdated
Comment thread src/card-service.ts Outdated
@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 14, 2026

补充一轮 review follow-up 修复,已同步到本 PR 并回写到对应线程。

本轮已完成并标记 resolved 的点:

  • rawInboundText / quoteContent 覆盖问题
  • dapi_usage 优先级回退问题
  • V2 stop/recovery 将 blockListJson 误当文本的问题
  • pending card 状态补充 lastStreamedContent / lastBlockListJson
  • sendProactiveMedia 测试 mock 导出名不一致
  • src/channel.ts 薄封装重构线程

验证:

  • npm run type-check
  • pnpm test

当前保留 open 的 review 点:

  • abort finalize 的 block type 语义
  • DINGTALK_CARD_TEMPLATE_ID 环境变量覆盖路径

@BrilliantWang
Copy link
Copy Markdown
Collaborator

BrilliantWang commented Apr 15, 2026

真机测试反馈(card-template-v2-clean)

环境:本地 openclaw + 直接部署 PR 分支代码

整体结论

核心流程(卡片创建、流式渲染、finalize、复制)都能正常工作,没有阻塞性问题。

问题

# 问题 严重程度 根因
1 Thinking 内容含 \n\n 时显示溢出,部分文本跑出引用块 P2 wrapProcessBlockMarkdown() 只在开头加 >,未逐行处理
2 流式输出偶现同一句话重复,过一会儿自动恢复 P3 contentLoop 和 blockList loop 独立节流,近乎同时 flush 时短暂重复
3 taskTime 未显示 P3 模板 footer 组件未绑定 ${taskInfo.taskTime}

UX 建议

  • 复制按钮会复制卡片全部文本(含顶部引用),疑似钉钉平台行为,待确认模板是否可控
  • footer 的 dapi_usage 显示的是钉钉 API 调用次数,纯个人认为对用户没有价值,反而是干扰。建议替换为 LLM token usage,或者直接去掉

截图

  • Thinking显示问题
Thinking显示问题
  • 重复输出
重复输出

@soimy
Copy link
Copy Markdown
Owner Author

soimy commented Apr 19, 2026

Token Usage Tracking — 通过 llm_output Hook 实现

方案选择

由于上游 ReplyPayload 不携带结构化 usage 数据且 PR 合并困难,采用零上游改动方案:通过 OpenClaw 已有的 llm_output typed plugin hook 捕获每次 LLM 调用的 token 消耗,在 DingTalk 插件内部累加后写入 card taskInfo

关联机制

onAgentRunStart(runId)  →  runId → { accountId, conversationId }
                                ↓
llm_output hook          →  lookup runId → accumulate usage
                                ↓
buildTaskInfoJson()       →  读取累加值 → 写入 card footer
                                ↓
card finalization         →  清理内存

新增文件

文件 用途
src/run-usage-store.ts runId↔session 映射 + token usage 累加器
tests/unit/run-usage-store.test.ts 11 个测试用例
tests/unit/llm-output-hook.test.ts hook 注册 + 事件处理验证
docs/plans/2026-04-19-card-taskinfo-token-usage-implementation.md 实施计划

修改文件

文件 变更
index.ts registerFull 中注册 api.on("llm_output", ...)
src/reply-strategy-card.ts onAgentRunStart 记录 runId 映射;buildTaskInfoJson() 读取 usage;card 终态时清理
docs/spec/2026-03-30-card-template-v2-design.md CardTaskInfo 新增 5 个字段

CardTaskInfo 扩展

export interface CardTaskInfo {
  model?: string;
  effort?: string;
  dap_usage?: number;
  taskTime?: number;
  // 新增
  inputTokens?: number;   // 输入 token(含 cache)
  outputTokens?: number;  // 输出 token
  cacheRead?: number;     // 缓存命中读取
  cacheWrite?: number;    // 缓存写入
  totalTokens?: number;   // 总计
}

Commits (6ab9a2c..e1ec736)

  • 6ab9a2c feat(card): add run-usage-store for tracking token consumption per session
  • 1d2e514 feat(card): register llm_output hook to capture token usage
  • fb9899d fix(test): type mockApi as OpenClawPluginApi
  • 872d17c feat(card): wire token usage tracking into card reply strategy
  • 9eea441 fix(test): add on mock to runtime-peer-index test
  • e1ec736 docs: add token usage tracking implementation plan

验证

  • 98 test files / 1016 tests 全部通过
  • TypeScript strict type-check 通过
  • Lint 无新增 error

soimy added 2 commits April 19, 2026 22:31
Switch run-usage-store from accountId:conversationId keys to runId keys.
Each agent run gets its own isolated usage accumulation, so clearing a
card's usage on finalization no longer wipes sibling agent runs sharing
the same conversation. Add runId to TaskMeta for lookup in
buildTaskInfoJson().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants