Skip to content

feat: support DingTalk streaming AI cards - #10818

Open
Accenemy wants to merge 7 commits into
AstrBotDevs:masterfrom
Accenemy:fix/dingtalk-stream-card
Open

Accenemy wants to merge 7 commits into
AstrBotDevs:masterfrom
Accenemy:fix/dingtalk-stream-card

Conversation

@Accenemy

@Accenemy Accenemy commented Oct 27, 2026 •

Copy link
Copy Markdown

中文说明

Related to #5785

Supersedes #8890 (closed after becoming stale and conflicted; GitHub no longer allowed reopening that PR).

这个 PR 为 AstrBot 内置钉钉平台适配器增加原生“互动 AI 卡片流式回复”支持。

目前 WebUI 里的钉钉设置已经有 card_template_id 配置项,并提示“启用后将使用互动卡片进行流式回复”,但实际 DingtalkMessageEvent.send_streaming() 仍然会把模型输出缓冲到结束后再作为普通消息发送。用户在钉钉里提问后需要等待完整生成结束才看到回复,体验上不是流式输出。

本 PR 基于已经在外部插件中验证通过的适配逻辑迁移到官方钉钉 adapter:填写钉钉互动卡片模板 ID 后,流式回复会创建钉钉 AI 卡片,并把模型生成的文本持续更新到卡片内容变量中;未配置模板或创建卡片失败时,会自动回退到原来的普通消息发送方式。

改动内容

  • 使用现有 card_template_id 作为钉钉流式卡片开关。
  • 通过 dingtalk_stream.AICardReplier 创建并投递钉钉互动 AI 卡片。
  • 将流式生成的纯文本内容按间隔更新到卡片中。
  • 新增可选配置 card_content_key,用于指定卡片模板中的内容变量名,默认 content。
  • 新增可选配置 card_update_interval,用于控制卡片更新最小间隔,默认 0.35 秒。
  • 未配置模板、缺少原始钉钉消息或卡片创建失败时,自动回退到原来的缓冲普通消息。
  • 对非纯文本消息段保留原有普通消息发送路径。
  • 在流式生成异常或取消时,也会尝试发送最终卡片状态并清理卡片会话,避免会话残留。
  • 若中间卡片更新失败,会停止无效的频繁更新但继续收集完整结果;最终完整更新仍失败时,自动回退为一条普通消息,避免回复截断或丢失。
  • 明确保留基类调用仅用于 AstrBot 发送状态与指标记录;新增事件层测试确认生成器只消费一次,卡片成功时不会重复发送普通消息。
  • 新增可选配置 send_plain_text_as_card(默认关闭),开启后将未来任务、定时任务、插件 context.send_message 等通过 send_by_session 主动发送的纯文本回复也投递为互动卡片。
  • 主动消息先通过 createAndDeliver 创建空卡片,再调用 /v1.0/card/streaming 写入正文并完成卡片,避免卡片已创建但内容为空。
  • 新增可选配置 animate_proactive_card(默认开启),让已经生成完成的主动消息分段更新到同一张卡片,提供“正在输出”的视觉反馈;单次最多更新 20 次。
  • 主动消息仅在消息链全部为纯文本时使用卡片;图片、语音、文件及混合消息继续走原有发送路径。
  • 去除正常回退分支中的重复提示日志,仅保留鉴权、HTTP 响应、JSON 解析和卡片接口异常等故障日志。
  • 对流式更新响应进行显式 HTTP/JSON 校验;最终更新失败时先将已投递卡片标记为失败并提示“完整回复已通过普通消息发送”,再执行普通消息回退,避免半成品卡片被误认为另一条有效答案。

主动消息与未来任务

AstrBot 的普通对话仍使用模型生成器提供的真实流式输出;未来任务、定时任务和插件主动消息通常只能在任务执行完成后拿到完整文本,因此这一类消息无法获得模型原始 token 流。本实现会先立即创建空卡片,再把完整结果按自适应分段更新到同一张卡片,最后发送完整正文并将卡片标记为完成,从视觉上保留“处理中 → 正在输出 → 输出完成”的连续体验。

卡片更新使用完整累计内容和覆盖语义(isFull=True),避免只发送 delta 时前文被替换。主动消息最多执行 20 次更新,并复用 card_update_interval 控制更新间隔;关闭 animate_proactive_card 后会直接写入最终内容。任何创建或更新失败都会返回原有普通消息路径,不影响现有功能。

达到的效果

1. 处理中

收到用户问题后,先创建钉钉互动 AI 卡片,并展示“该问题正在处理中...”的占位状态。这样用户发送问题后能立即看到机器人已经接收并开始处理请求,不需要等到模型完整生成结束才看到第一条回复。

DingTalk AI card processing state

2. 输出结果中

模型开始生成内容后,卡片进入流式输出状态,标题显示“正在输出结果...”,内容区域会随着模型生成不断刷新已经产出的文本。用户可以边等边看当前回答进度,避免普通消息模式下长时间无反馈的割裂感。

DingTalk AI card streaming state

3. 输出结果

模型生成完成后,卡片更新为最终状态,标题显示“输出结果”,内容区域展示完整回复。最终结果仍保留在同一张卡片中,不会额外刷出多条普通消息。

DingTalk AI card final result state

卡片的具体标题、布局、按钮和视觉样式均可在钉钉开发者平台的互动卡片模板中自定义;本 PR 只负责将 AstrBot 的流式输出接入钉钉 AI 卡片,并把内容写入配置的模板变量,例如默认的 content。

关于完整内容更新

当前实现会在每次卡片更新时发送完整的 full_content,这是有意保留的行为。钉钉互动 AI 卡片这里使用的是覆盖式内容更新语义;在 append=False 的更新方式下,如果只发送新增 delta,卡片内容可能只保留最新片段,导致前文丢失。

为了控制请求频率和 payload 增长,本 PR 通过 card_update_interval 对更新节奏做限流,默认最小间隔为 0.35 秒。后续如果钉钉侧确认可稳定使用追加式更新,也可以再切换为 delta 追加模式。

验证情况

  • 已同步并合并官方最新 master(dad255c,2026-10-27),冲突已解决;相对最新主线仍仅修改 4 个钉钉相关文件。
  • Python 3.12 执行 pytest tests/test_dingtalk_adapter.py -q:25 passed。
  • 完整测试集:4937 passed, 14 skipped;另有 2 个 Dashboard shell-session 生命周期用例失败。已在未修改的官方 master 临时检出中复跑,两个用例同样失败,因此属于当前上游基线/本地 macOS 环境问题,并非本 PR 引入。
  • 官方 scripts/smoke_startup_check.py:通过。
  • ruff check .、ruff format --check .、py_compile 和 PR 差异 git diff --check 均通过。
  • 没有引入新的第三方依赖。
  • 已在 Docker Compose 部署的 AstrBot v4.26.7 上使用同逻辑插件 v0.2.3 完成钉钉实机验证:
    • 普通对话可使用互动 AI 卡片真实流式更新;
    • 未来任务的主动消息可创建互动卡片,不再退化为普通消息;
    • 通过“先创建空卡片、再调用 streaming 接口写入”的两阶段流程,最终 Markdown 正文可以正常显示,不再出现空卡片;
    • 主动消息会展示分段输出动画,并在同一张卡片中完成最终内容;
    • 卡片接口不可用或消息含媒体组件时仍保留原有普通消息发送路径。

Checklist / 检查清单

  • This is NOT a breaking change. / 这不是一个破坏性变更。

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。相关讨论见 [Feature]功能请求:适配钉钉打字机效果流式 AI 卡片 #5785。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入新依赖库的同时将其添加到 requirements.txt 和 pyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

English Summary

This PR adds native DingTalk interactive AI card streaming support to the built-in DingTalk platform adapter. When card_template_id is configured, streaming text chunks are delivered through a DingTalk AI card via dingtalk_stream.AICardReplier; otherwise the adapter falls back to the existing buffered normal-message behavior.

The user experience has three visible states: a processing card is shown immediately after the user asks a question, the same card is continuously updated while the model is generating, and the final card displays the complete answer. The card title, layout, buttons, and visual style can be customized in the DingTalk developer platform's interactive card template; this PR only connects AstrBot's streaming output to the configured card content variable.

The card update path intentionally sends the full accumulated content on each update because the current DingTalk AI card update flow is used with overwrite semantics (append=False). Sending only delta text in that mode can cause the card to display only the latest chunk instead of the whole generated answer. Update frequency is throttled by card_update_interval.

Optional send_plain_text_as_card support also covers proactive plain-text replies sent by future tasks, scheduled jobs, and plugins. Proactive messages are created as empty cards and then populated through DingTalk's streaming endpoint; when animate_proactive_card is enabled, the completed text is replayed in at most 20 accumulated updates to provide continuous visual feedback. Mixed or media messages and any failed card operation retain the existing normal-message fallback.

Summary by Sourcery

Enable DingTalk interactive AI cards for streaming and eligible proactive text replies while preserving robust normal-message fallbacks.

New Features:

  • Add native DingTalk interactive AI card streaming for model responses when a card template is configured.
  • Support optional interactive cards for proactive plain-text messages, including future tasks, scheduled jobs, and plugin-initiated messages with configurable output animation.

Bug Fixes:

  • Preserve reliable fallback to normal messages when card creation, updates, finalization, source-message access, or unsupported message content prevents card delivery.
  • Prevent duplicate normal-message delivery and ensure card sessions are cleaned up or marked failed after streaming errors.

Enhancements:

  • Add configurable card content variable names and update intervals while retaining normal delivery for mixed or media messages.

Tests:

  • Expand DingTalk adapter coverage for card routing, proactive card updates, streaming accumulation, failure handling, fallback behavior, and duplicate-send prevention.

@Accenemy Accenemy mentioned this pull request Oct 27, 2026
5 tasks done

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Needs a human reviewer. If a card is delivered but a streaming update or finalization fails, the fallback can send a normal reply as well, leaving users with an already-delivered card and a duplicate message. Reverting stops future behavior but cannot retract messages already sent.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Copy link
Copy Markdown
Author

已处理最新 Sourcery review 提到的失败回退体验:

  • dingtalk_stream.AICardReplier.async_streaming() 会在内部吞掉 HTTP 异常,因此改为复用相同的 /v1.0/card/streaming 请求语义并显式校验 HTTP/JSON 响应,让 AstrBot 能可靠识别更新失败;
  • 最终更新失败时保留完整普通消息回退,同时先通过互动卡片更新接口将原卡片设为 FAILED,正文提示“卡片输出失败,完整回复已通过普通消息发送”,避免空卡片/半成品卡片看起来像第二条有效答案;
  • 成功完成时正常清理卡片会话;最终更新失败时保留会话直到失败状态写入后再清理;
  • 新增 3 个回归用例。钉钉专项现为 25 passed,Smoke Test、Ruff、格式和编译检查通过;完整测试为 4937 passed, 14 skipped,仅保留已在未修改官方 master 复现的 2 个无关 Dashboard 用例失败。

成功路径的卡片创建、累计全文覆盖更新及最终完成行为没有改变。

Copy link
Copy Markdown
Author

@anka-afk 已按您在 #8890 中的建议完成冲突处理并同步到最新 master,由于旧 PR 无法重新打开,现由本 PR 继续。最新 review 提到的失败回退体验也已修正并补充测试。当前 5 个 fork Actions 均为 action_required,烦请方便时批准工作流并重新审核,谢谢。

This branch has not been deployed

No deployments
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.

1 participant