Skip to content

feat:支持使用自定义的AI卡片模版 - #550

Open
ziyilin wants to merge 1 commit into
DingTalk-Real-AI:mainfrom
ziyilin:customize-AI-card
Open

feat:支持使用自定义的AI卡片模版#550
ziyilin wants to merge 1 commit into
DingTalk-Real-AI:mainfrom
ziyilin:customize-AI-card

Conversation

@ziyilin

@ziyilin ziyilin commented Apr 29, 2026

Copy link
Copy Markdown

Summary

  • 支持自定义钉钉 AI 卡片模板,通过配置项指定 cardTemplateIdcardTemplateKey 替换内置模板,使不同业务场景可以使用各自的卡片样式
  • 新增 TOPIC_CARD Stream 回调处理器,接收用户点赞/点踩操作并更新卡片状态
  • 卡片回调的 actionId 和变量名支持自定义配置(cardLikeActionIdcardDislikeActionIdcardLikeVar),适配不同卡片模板的按钮定义
  • 用户反馈(点赞/点踩)自动记录到 OpenClaw session JSONL 文件,供会话统计插件消费
  • 单聊和群聊场景均已实测验证通过

解决issue:#540

变更内容

1. 自定义 AI 卡片模板支持(af86a20)

问题:之前 AI 卡片模板 ID 硬编码为 02fcf2f4-5e02-4a85-b672-46d1f715543e.schema,内容字段名固定为 msgContent,无法使用自定义卡片模板。

方案:新增 5 个可选配置项(均有默认值,不影响已有配置),将模板 ID、内容字段名、回调按钮 ID 全部参数化。

新增配置项

配置项 类型 默认值 说明
cardTemplateId string 02fcf2f4-...schema AI 卡片模板 ID
cardTemplateKey string msgContent 卡片内容 Markdown 变量名
cardLikeActionId string ai_res_like 点赞按钮的回调 actionId
cardDislikeActionId string ai_res_dislike 点踩按钮的回调 actionId
cardLikeVar string like 卡片中表示赞踩状态的变量名

配置位置:openclaw.json 中的 dingtalk-connector 插件配置。支持两种配置模式:

单账号模式(直接在插件顶层配置):

{
  "dingtalk-connector": {
    "clientId": "xxx",
    "clientSecret": "xxx",
    "cardTemplateId": "your-custom-template-id.schema",
    "cardTemplateKey": "content",
    "cardLikeActionId": "thumbs_up",
    "cardDislikeActionId": "thumbs_down",
    "cardLikeVar": "feedbackStatus"
  }
}

多账号模式(在 accounts[].config 中配置,可按账号设置不同模板):

{
  "dingtalk-connector": {
    "accounts": [
      {
        "clientId": "xxx",
        "clientSecret": "xxx",
        "config": {
          "cardTemplateId": "your-custom-template-id.schema",
          "cardTemplateKey": "content"
        }
      }
    ]
  }
}

代码变更

  • src/services/messaging/card.ts:将硬编码的 AI_CARD_TEMPLATE_ID 改为 DEFAULT_AI_CARD_TEMPLATE_IDcreateAICardForTargetstreamAICardfinishAICard 均从 config 读取 cardTemplateIdcardTemplateKey
  • src/config/schema.ts:在 DingtalkSharedConfigShape 中新增 5 个 optional 字段
  • openclaw.plugin.json:在顶层和 accounts 两处 JSON Schema 中注册新字段
  • src/core/message-handler.ts:移除本文件中重复定义的 AI_CARD_TEMPLATE_IDAICardStatus 常量(已统一到 card.ts
  • src/reply-dispatcher.ts:移除非 QPS 错误时的立即降级逻辑,改为等待下一次 partial 更新重试

TOPIC_CARD 回调处理器

src/core/connection.ts 中新增 TOPIC_CARD Stream 回调监听器:

  • 解析回调数据(支持 content 二次 JSON 解析)
  • cardPrivateData.actionIds 判断是点赞还是点踩
  • 根据配置的 cardLikeActionId/cardDislikeActionId/cardLikeVar 构造响应
  • 点踩时解析 dislike_reasoncustom_dislike_reason 参数
  • 通过 socketCallBackResponse 响应回调(finally 块保证无论异常都响应,避免钉钉超时重试)

2. 用户反馈记录到 Session(c46fa86)

问题:用户的点赞/点踩行为仅在卡片上生效,无法在会话历史中留存,统计插件无法获取用户满意度数据。

方案:新增 card-session 内存映射注册表,在卡片创建时注册 cardInstanceId → sessionKey 映射,在回调触发时查找映射并将反馈追加到 session JSONL 文件。

数据流

卡片创建(reply-dispatcher.ts) → registerCardSession(cardInstanceId, sessionKey)
                                         ↓ (内存 Map)
用户点赞/踩(connection.ts)    → recordFeedbackToSession(outTrackId, like, userId)
                                         ↓
                               查找 sessions.json → 定位 JSONL 文件
                                         ↓
                               appendFile → session JSONL

JSONL 条目格式

统计插件通过 type === "custom" && customType === "user-feedback" 过滤反馈条目。

点赞:

{
  "type": "custom",
  "customType": "user-feedback",
  "data": {
    "like": 1,
    "userId": "194584",
    "cardInstanceId": "card_1777440689892_pwm21ymr",
    "source": "dingtalk-card"
  },
  "id": "12bbd507",
  "parentId": null,
  "timestamp": "2026-04-29T05:31:41.309Z"
}

点踩(含原因):

{
  "type": "custom",
  "customType": "user-feedback",
  "data": {
    "like": -1,
    "userId": "194584",
    "cardInstanceId": "card_1777440689892_pwm21ymr",
    "source": "dingtalk-card",
    "dislikeReasons": ["回答不准确", "太啰嗦"],
    "customDislikeReason": "没有给出具体代码"
  },
  "id": "c80d4b7c",
  "parentId": null,
  "timestamp": "2026-04-29T05:32:24.593Z"
}

like 字段为数字类型:1 = 点赞,-1 = 点踩。

重复反馈

用户可以反复点赞或点踩同一张卡片,每次操作追加新条目。统计时以同一 (cardInstanceId, userId) 的最后一条为准(群聊中多用户可对同一卡片独立反馈)。

核心模块

  • src/services/card-session-registry.ts(新增):内存注册表(Map<cardInstanceId, {sessionKey, agentId}>,24h TTL + 30min 自动清理)+ recordFeedbackToSession() 异步写入
  • src/reply-dispatcher.ts:新增 sessionKey 参数,在两处卡片创建路径调用 registerCardSession()
  • src/core/message-handler.ts:将已有 sessionKey 变量传递给 createDingtalkReplyDispatcher()
  • src/core/connection.ts:在 TOPIC_CARD 回调中 fire-and-forget 调用 recordFeedbackToSession()

文件变更

文件 变更类型 说明
openclaw.plugin.json 修改 新增 5 个卡片配置项 JSON Schema
src/config/schema.ts 修改 新增 5 个 optional 配置字段
src/core/connection.ts 修改 新增 TOPIC_CARD 回调处理器 + 反馈记录调用
src/core/message-handler.ts 修改 移除重复常量 + 传递 sessionKey
src/reply-dispatcher.ts 修改 支持 sessionKey 参数 + 注册卡片映射
src/services/messaging/card.ts 修改 模板 ID 和内容字段名参数化
src/services/card-session-registry.ts 新增 卡片-会话映射注册表 + 反馈写入
tests/card-feedback/card-feedback.test.ts 新增 13 个单元测试
USER_FEEDBACK.md 新增 反馈功能文档(含 PR 描述 + 统计消费指南)

测试

  • 单元测试:13 个测试全部通过(vitest run tests/card-feedback/card-feedback.test.ts
    • 注册/查找/覆盖
    • TTL 过期清理
    • 点赞/点踩写入验证
    • 点踩原因字段验证
    • 异常路径(空 outTrackId、未知卡片、sessions.json 不存在、sessionKey 不存在、sessionFile 已删除)
  • 集成测试:
    • 单聊(admin agent):点赞/点踩反馈正确记录到 session JSONL
    • 群聊(main agent):点赞/点踩反馈正确记录到 session JSONL
  • 构建:npx tsdown 通过

注意事项

  • 所有新增配置项均为 optional 且有默认值,完全向后兼容,不影响使用内置模板的已有部署
  • 内存映射表在进程重启后清空,重启前创建的卡片如收到反馈将无法定位 session(日志输出 warn 提示)
  • 反馈写入为 fire-and-forget 异步操作,不阻塞卡片回调响应

@meng93 meng93 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ziyilin 感谢贡献 🙏 整体方向(自定义模板 + 用户反馈记录)有价值。已对照当前 main(注:你的 base 落后 30 个 commit,建议先 rebase)逐条核对,下面是分级建议。修复后我们当天合入 🚀

🔴 P0(必须修复)

  1. reply-dispatcher.ts:710 移除 sendFallbackErrorMessage 是范围外回退
    diff 中将非 QPS 错误分支的 await sendFallbackErrorMessage('sendMessage', err.message); 删掉,改成"等下一次 partial 重试"。这一改动与 PR 标题(自定义模板 + 反馈)无关,且 partial 重试不一定会发生(流结束就再没机会),用户会直接没感知。请:
    • 还原此行;或
    • 拆出独立 PR 并附说明("closeStreaming/finishAICard 已能兜底"需给出代码路径证据)

🟡 P1(建议修复后合入)

  1. card.ts 删除 staticMsgContent: "" 写入需要保留
    原代码在 streamAICard / finishAICard 都把 staticMsgContent: "" 写空。本 PR 直接删掉,对依赖该字段做静态展示的默认模板(02fcf2f4-...schema)存在静默行为变化风险。请保留:

    • 默认模板路径contentKey === DEFAULT_CARD_TEMPLATE_KEY)下继续写入 staticMsgContent: ""
    • 自定义模板路径下不写(避免污染用户自定义字段)
  2. PR 描述中的 USER_FEEDBACK.md 实际未提交
    描述里写"新增反馈功能文档",但 gh pr view --json files 中没有这个文件。请补到 docs/USER_FEEDBACK.md(与 MULTI_AGENT_SETUP.md 等保持一致)。

  3. TOPIC_CARD 解析失败回包 {} 与现有 TOPIC_ROBOT 风格不一致
    现存 TOPIC_ROBOT 错误路径回包 { success: true }(见 main connection.ts:563)。新 TOPIC_CARD 解析失败回 {},建议至少补 success: false 字段或对齐到与正常路径同构的最小响应(cardData.cardParamMap: {}),避免钉钉侧不必要的重试判定。

  4. TOPIC_CARD listener 没有跟随 cleanup
    虽然现状的 TOPIC_ROBOT 也没清理(属于历史问题),但既然新增了一个 listener,建议借这次把 cleanup 一起补上:保留 listener 引用,cleanup 中 off 掉。否则 monitorSingleAccount 任何重入路径都会叠加。

  5. 测试覆盖偏窄
    13 个 case 全部针对 card-session-registry,PR 真正的高风险逻辑没覆盖:

    • TOPIC_CARD 回调(actionId 解析、自定义 actionId 生效、点踩原因)
    • card.ts 自定义 contentKey 替换路径
      建议至少补 3~4 个 callback 单测(可 mock client + 直接断言响应结构)。
  6. cardLikeVar 命名不清
    Var 表义模糊。建议改为 cardLikeStateKeycardFeedbackStatusKey,让配置文件读起来自解释。

  7. OpenClaw session JSONL 直写存在跨仓耦合
    appendFile 直接写到 ~/.openclaw/agents/.../sessions/*.jsonl,强耦合 OpenClaw 的目录约定与 schema。短期 OK,但建议:

    • docs/USER_FEEDBACK.md 明确写明这是"插件对 OpenClaw 私有目录的约定写入"
    • 中期跟进 OpenClaw 提供 custom-event API 后切换

🟢 P2(优化项,可后续 follow-up)

  1. 并发写入注释偏弱appendFile 在快速点击场景下与 OpenClaw session writer 的潜在竞争,建议加一次"短时连点"压测;或写入前判断尾部是否为 \n 避免空行。
  2. card-session-registry 内存 Map 无上限:建议加 size 监控日志或硬上限(如 50k)+ LRU 淘汰。
  3. TS any 用得偏多callbackData / parsedContent 建议抽 DingtalkCardCallbackPayload 类型。
  4. _getRegistryForTesting 暴露给所有 consumer:可改用 vitest 的模块 mock,避免在生产代码里出现测试专用 API。
  5. recordFeedbackToSession 内部已 try/catch 包住,connection.ts 里的 .catch() 永远不会触发:可移除冗余 .catch() handler。
  6. 24h TTL 隐藏行为:建议在 docs/USER_FEEDBACK.md 写明"机器人重启或卡片超过 24h 后的反馈不会落库"。

辛苦 @ziyilin 跟进 P0 1 条 + P1 7 条;P2 可以本 PR 一起做,也可以 follow-up。改完打个招呼,当天合入 🚀

Add TOPIC_CARD Stream callback handler for custom AI Card templates,
supporting like/dislike feedback. Introduces 3 optional config fields
(cardLikeActionId, cardDislikeActionId, cardLikeVar) so different
card templates can customize callback action IDs and variable names.
@ziyilin
ziyilin force-pushed the customize-AI-card branch from c46fa86 to 99dd48f Compare May 20, 2026 05:45
@ziyilin

ziyilin commented May 20, 2026

Copy link
Copy Markdown
Author

@meng93 感谢 review!所有 P0 + P1 已修复,合并为一个commit。逐条对应如下:


🔴 P0

# 评审意见 修复说明
1 reply-dispatcher.ts:710 误删 sendFallbackErrorMessage 已还原。见 src/reply-dispatcher.ts:816 — 非 QPS 错误分支恢复 await sendFallbackErrorMessage('sendMessage', err.message),并补充注释说明 QPS 限流由退避重试兜底、真正无法恢复的错误才走此降级

🟡 P1

# 评审意见 修复说明
2 card.ts 删除 staticMsgContent: "" 导致默认模板行为变化 ✅ 见 src/services/messaging/card.ts:493-495L641-643。仅在 contentKey === DEFAULT_CARD_TEMPLATE_KEY 时写入 staticMsgContent: "";自定义模板路径不写,避免污染
3 USER_FEEDBACK.md 未提交 ✅ 已补充到 docs/USER_FEEDBACK.md(211 行),包含功能说明、JSONL schema、统计消费指南
4 TOPIC_CARD 解析失败回包 {} 与 TOPIC_ROBOT 不一致 ✅ 见 src/core/connection.ts:708,已改为 { success: false } 与 TOPIC_ROBOT 风格对齐
5 TOPIC_CARD listener 无 cleanup ✅ 保留 cardCallbackListener 引用,在 三个 cleanup 路径中均调用 client.deregisterCallbackListener?.(TOPIC_CARD, cardCallbackListener) — 见 L519(onAbort)、L794(cleanup)、L834(enhancedCleanup)
6 测试覆盖偏窄(仅 card-session-registry) ✅ 新增两个测试文件:
tests/card-feedback/card-callback.test.ts — 11 个用例覆盖 TOPIC_CARD 回调(actionId 解析、自定义 actionId、点踩原因、异常路径)
tests/card-feedback/card-contentkey.test.ts — 4 个用例覆盖 card.ts 自定义 contentKey 替换路径
总计 28 tests all pass
7 cardLikeVar 命名不清 ✅ 已重命名为 cardFeedbackStatusKey,自解释。见 src/config/schema.ts:87openclaw.plugin.jsonconnection.ts:735
8 OpenClaw session JSONL 直写跨仓耦合需文档说明 ✅ 见 docs/USER_FEEDBACK.md L196-203,专设"OpenClaw Session JSONL 直写耦合"小节,写明:私有目录约定 + 并发安全评估 + 中期迁移 custom-event API 计划

额外修复A:消息拆分后反馈丢失问题

问题:当 AI 回复内容超长时,dingtalk-connector 会将消息拆分为多条(通过 outbound.sendText 路径发送)。拆分产生的卡片走的是 channel 的 outbound 通道而非 reply-dispatcher,该路径无法获得 sessionKey,导致这些卡片收到点赞/点踩后无法追溯到对应 session,反馈直接丢失。

修复方案

  1. Target 反推 sessionKey:新增 guessSessionKeyByTarget() — 根据 outbound 的发送目标(userId/conversationId),在已注册的映射表中通过 sessionKey 中的 target 模式匹配来推断正确的 session。含安全检查:多个候选 sessionKey 不同时,放弃推断以避免跨用户串话。

  2. Outbound 路径注册:见 src/channel.ts:345-367outbound.sendText 成功创建卡片后,调用 guessSessionKeyByTarget 推断 session 并 registerCardSession

额外修复 B:进程重启场景下反馈丢失(顺带响应 P2 #14

问题:原实现注册表只在内存里。dingtalk-connector 一旦重启(升级、OOM、宿主重启……),所有已发出去但用户尚未点击的卡片,再收到点赞/点踩时都查不到 session 映射,反馈全部丢失。

** 修复**:

  • 新增 lazy 持久化文件 getPersistFile() → ${tmpdir}/dingtalk-connector/card-session-map.json
  • registerCardSession 异步 fire-and-forget 写盘(saveToDisk),不阻塞主流程
  • 首次 lookupCardSession 时 lazy 加载历史映射(loadFromDisk),跳过 createdAt 已超过 24h 的条目

验证

检查项 结果
单元测试 ✅ 28/28 pass (vitest run tests/card-feedback/)
构建 npx tsdown 通过,29 文件 422KB
集成测试 ✅ 单聊/群聊点赞点踩均正确记录到 session JSONL

关于 P2 项目

P2(#9-#13)作为 follow-up 跟进,不在本 PR 范围内。如有需要可以另开 issue 追踪。


@jige111

jige111 commented Jun 25, 2026

Copy link
Copy Markdown

你好,请问这里的实现用的是ai卡片自带的功能区按钮吗,还是自定义的按钮。
我好像用自带按钮没法收到回调,但是自定义的按钮可以收到回调。

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