Skip to content

feat(channels): rich-text markdown rendering for feishu and dingtalk - #99

Open
skywclouds wants to merge 4 commits into
OpenBMB:mainfrom
skywclouds:main
Open

feat(channels): rich-text markdown rendering for feishu and dingtalk#99
skywclouds wants to merge 4 commits into
OpenBMB:mainfrom
skywclouds:main

Conversation

@skywclouds

Copy link
Copy Markdown
Contributor

概述

为飞书和钉钉渠道的出站消息增加 Markdown 富文本渲染能力。新增零依赖手写 Markdown 解析器,飞书走 post 富文本、钉钉走原生 markdown 透传 + 围栏补全。通过全局开关 CHANNEL_RICH_RENDER_ENABLED(默认开启)控制,关闭后回退纯文本行为。

动机

此前飞书和钉钉的出站消息均以纯 text 类型发送,AI 回复中的标题、代码块、列表、粗体、行内代码等 Markdown 语法全部以纯文本呈现,可读性差。尤其是代码——缩进丢失、无等宽字体、函数体与说明文字混为一团。

改动范围

只做出站渲染,不改入站解析、不改微信/企微、不改前端控制台。

文件说明

backend/app/channels/markdown_render.py 新增:Markdown 解析器 + 飞书 post 渲染 + 钉钉围栏补全 + 分块 + title 提取
backend/app/channels/adapters/feishu.py send() 富文本化:含 markdown 走 post,纯文本走 text
backend/app/channels/adapters/dingtalk.py send() 富文本化:含 markdown 走 markdown msgtype + ensure_code_fences 围栏补全
backend/app/config.py 新增 channel_rich_render_enabled: bool = True
backend/.env.example 新增 CHANNEL_RICH_RENDER_ENABLED="true"
backend/tests/test_markdown_render.py 新增:解析器 + 渲染器 + 围栏补全 52 项单测
backend/tests/test_feishu_adapter.py 新增 7 项富文本 send 测试
backend/tests/test_channel_dingtalk.py 新增 11 项富文本 send 测试

技术方案

Markdown 解析器(markdown_render.py,零新增依赖):

  • 块级语法:标题 / 围栏代码块 / 4 空格缩进代码块 / 顶格代码块 / 有序无序列表 / 引用 / 分隔线 / 表格
  • 行内语法:粗体 / 斜体 / 行内代码 / 链接(token 扫描 + 递归下降,代码片段占位隔离不二次解析)
  • has_markdown(text) 检测:命中任一 markdown 语法标记返回 True,纯文本返回 False 以走原 text 路径

飞书渲染:

  • parse_markdown → 块模型 → render_feishu_post → 飞书 post content 结构
  • CodeBlock → code_block tag(等宽字体、保留缩进)
  • 含多行内代码的段落正确渲染

钉钉渲染:

  • 原生 markdown 透传,ensure_code_fences 自动为顶格代码补 ``` 围栏
  • extract_dingtalk_title 提取标题(首标题 → 首行 → 默认"消息",截断 ≤20 字)

顶格代码块识别:

  • AI 输出常以 def/class/import/from ... import/async def/@decorator 顶格开头输出代码,无围栏无 4 空格缩进
  • _CODE_START_RE 检测代码起始行,_is_code_continuation 保守判断续行(缩进行、# 注释、赋值、函数调用)
  • has_markdown 也检测顶格代码起始行,确保纯代码消息走富文本路径

开关与回退

  • CHANNEL_RICH_RENDER_ENABLED=true(默认):启用富文本渲染
  • CHANNEL_RICH_RENDER_ENABLED=false:完全回退到原有纯文本行为
  • 飞书:has_markdown 返回 False 的消息始终走 text 路径,无外观变化
  • 钉钉:同上,走 text msgtype

测试

  • 全量 1450 项测试通过
  • ruff 检查干净
  • 新增 70 项单测覆盖解析器、渲染器、围栏补全、飞书/钉钉 adapter send

风险

  • 飞书 post 消息与 text 消息外观有差异(富文本格式),关闭开关可完全回退
  • 顶格代码块续行判断是启发式的,极端情况下可能将非代码行误纳入代码块或截断代码块
  • 钉钉 markdown 渲染能力受钉钉自身限制(不支持表格、部分嵌套语法)

Add markdown_render.py with a hand-written parser (zero new deps) that
covers headings, bold, inline code, fenced/indented/toplevel code blocks,
lists, quotes, links, tables, and thematic breaks.

Feishu: messages containing markdown are rendered as post rich text with
code_block tags; plain text falls back to the original text msgtype.

DingTalk: messages containing markdown are sent as native markdown with
automatic code-fence insertion (ensure_code_fences) so toplevel code
(def/class/import) is properly rendered by DingTalk's markdown engine.

Key fixes during development:
- Fix infinite loop in _parse_inline_recursive when multiple inline code
  segments were present (code placeholder regex used text[pos:] causing
  relative match.end() to never advance pos past the second placeholder).
  This caused 95% CPU spin -> health check failure -> app crash loop.
- Add 4-space indented code block support.
- Add toplevel code block detection (def/class/import/from/async def/@decorator)
  with conservative continuation heuristics (indent lines, # comments,
  assignments, function calls).
- Add toplevel code start to has_markdown detection so code-only messages
  are routed through the rich-text path.

Config: CHANNEL_RICH_RENDER_ENABLED (default true) gates the feature;
setting it to false restores the original plain-text behavior.

Tests: 1450 passed, ruff clean.
@fadeoreo

Copy link
Copy Markdown
Collaborator

#99 目前有一个需要修改的 P1 问题
[P1] Markdown 分片会破坏代码块

位置:

  • backend/app/channels/markdown_render.py:534
  • 飞书调用:backend/app/channels/adapters/feishu.py:419
  • 钉钉调用:backend/app/channels/adapters/dingtalk.py:556

split_markdown_by_lines() 的注释说会避免把代码块切开,但实际上只按普通行长度切分。比如:

```python
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

当单行超过渠道限制时,会被拆成:

```text
```python
xxxxxxxxxxxxxxxxxxxx
xxxxxxxxxx

实际发送时,飞书会把第一段和后续段分别转成不同的 `post` 消息,钉钉也会分别发送多个 Markdown 消息。这样代码围栏会跨消息断裂,导致代码块被当成普通文本或渲染异常。`ensure_code_fences()` 也无法修复,因为每个 chunk 单独处理时,围栏信息已经丢失。

建议:

- 分片时显式跟踪 fenced code block 状态;
- 每个 chunk 独立闭合并重新打开代码围栏;
- 或者代码块超限时降级为纯文本/附件;
- 增加“超长 fenced code block”的回归测试,验证每个发送 chunk 都是合法 Markdown。

除此之外:

- #99 与当前 `main` 没有新的 Git merge conflict;
- 针对性测试通过:`138 passed`;
- 飞书和钉钉的普通标题、粗体、链接、列表、引用等渲染逻辑整体方向是对的;
- 前面 #95 的微信/企微附件功能不在这个 PR 里,#99 只覆盖飞书和钉钉出站 Markdown。

split_markdown_by_lines now tracks fenced code block state. When a split
point falls inside a code fence, the previous chunk is closed and the
next chunk reopens the fence with the original language, so every chunk
is self-contained valid Markdown. Fixes broken code fences on feishu
post and dingtalk markdown when messages exceed the channel length limit.

Adds 9 regression tests covering overlong fenced blocks, language
preservation, content recovery, tilde fences, and adapter-level chunk
validation.
@skywclouds

Copy link
Copy Markdown
Contributor Author

感谢详细的 review,P1 问题已修复并推送(commit 204e4c7)。

修复方案

重写了 split_markdown_by_lines()(backend/app/channels/markdown_render.py:551),新增围栏代码块状态跟踪:

  • 新增 _detect_fence() / _is_fence_close() 辅助函数识别围栏起止行
  • 当切分点落在围栏代码块内部时,在前一段末尾补闭合围栏、下一段开头按原语言重新打开围栏,确保每个 chunk 都是自包含的合法 Markdown
  • 同时支持 ``` 和 ~~~ 两种围栏;未闭合围栏(文末)也会补上闭合围栏
  • 超长单行硬切时同样会重新打开/闭合围栏
    这样飞书 parse_markdown(chunk) 每段都得到完整 CodeBlock,钉钉 ensure_code_fences(chunk) 不会因围栏已存在而重复处理。选用了你建议的第二种方案(每个 chunk 独立闭合并重新打开代码围栏),因为它不改变现有渠道发送语义、无需降级为附件。

回归测试

新增 9 项测试覆盖该场景:

  • test_markdown_render.py:超长围栏块每段围栏成对、保留语言标识、内容可拼回、短块不切、~~~ 围栏、代码块+后续文本、超长单行
  • test_feishu_adapter.py:每个发送 chunk 含 code_block tag,代码内容完整覆盖首尾行(line_0 到 line_299)
  • test_channel_dingtalk.py:每个发送 chunk 围栏成对、不超长度限制

验证

  • 全量 1459 passed
  • ruff check 对改动文件干净(test_feishu_adapter.py 残留的 I001 import 排序为既有问题,非本次改动引入,diff 未触及任何 import 行)

关于您提到的其余几点确认如下:

@skywclouds

Copy link
Copy Markdown
Contributor Author

概述

为飞书渠道增加实时展示智能体执行步骤的能力。用户在飞书发消息后,机器人会创建一张独立交互式卡片,随智能体执行(SOP 匹配 / 步骤推进 / 工具调用 / 知识检索)逐步更新,结束后定格为完成或失败状态。正文回复仍走原有 outbox 投递,与卡片互不影响。

动机

网页端对话已能逐步展示智能体解决问题的过程(匹配到哪个 SOP、执行到哪一步、调用了什么工具、检索了什么知识),但飞书渠道此前只能向用户展示一个最终结果文本,看不到中间过程。用户在飞书中无法感知智能体"正在做什么",体验上缺乏透明度和信任感。

改动范围

仅飞书渠道生效,不改微信/企微/钉钉、不改网页端、不改入站解析、不改正文回复投递机制。卡片是"进度展示",不进入 outbox 重试体系。

文件说明

  • backend/app/observability/event_log.py 修改:EventLog.init 增加 event_sink 钩子;record() 落库后调用 sink 传递 (event_type, traced_payload),异常仅记日志不抛出
  • backend/app/core/agent_loop.py 修改:AgentLoop.init(db, *, event_sink=None) 透传给 EventLog
  • backend/app/config.py 修改:新增 channel_feishu_trace_enabled: bool = True
  • backend/app/channels/adapters/feishu.py 修改:_request() 支持 PATCH 方法;新增 create_card()(发送 msg_type=interactive 卡片,返回 message_id)和 update_card()(PATCH /im/v1/messages/{message_id} 更新卡片内容)
  • backend/app/channels/feishu_trace.py 新增:FeishuTraceStreamer 模块——start/on_event/finish/abort 生命周期管理
  • backend/app/channels/service_intake.py 修改:handle_turn 处接入 streamer,飞书 + 开关开启时创建卡片并透传 event_sink,finally 中 finish/abort
  • backend/tests/test_event_log_sink.py 新增:3 项测试覆盖 sink 调用/payload、None 不抛、sink 异常不传播
  • backend/tests/test_feishu_trace_streamer.py 新增:10 项测试覆盖 start/节流/错误隔离/finish/abort/开关判定
  • backend/tests/test_feishu_adapter.py 修改:新增 7 项 card 测试 + FakeClient 增加 patch 方法
  • 6 个 test_channel_*.py 修改:RecordingAgentLoop.init 增加 event_sink=None 适配新签名
  • backend/app/channels/feishu_path.md 修改:链路图补充卡片创建/更新/定格节点 + 设计说明
  • development-feishu-channel.md 修改:新增 §8.1 实时执行步骤卡片章节
  • frontend-enterprise/src/pages/channels/FeishuSetup.tsx 修改:配置页增加能力说明

技术方案

单一事件钩子:所有 trace 事件都流经 EventLog.record,只需在这一处加一个可选 event_sink 即可覆盖 SOP/步骤/工具/知识全部类型。event_sink 默认 None,网页端行为完全不变。
复用网页端渲染逻辑:FeishuTraceStreamer.on_event 用 _SinkEvent(轻量 AgentEvent 替身)包装 (event_type, payload),调用网页端同款 _event_trace_lines(event, skill_names, skill_hint) 把事件转成可读行(text/detail/state/icon),保证飞书与网页端展示一致。
节流:卡片更新带最小 1s 间隔,高频事件合并为一次 PATCH,规避飞书消息更新限流。finish() / abort() 强制刷新最终状态。
失败隔离:卡片创建/更新全程 try/except,失败仅记日志,绝不影响 turn 成功与回复投递。start() 失败时 message_id 为 None,后续 on_event / finish 全部静默跳过。
卡片状态:running(蓝色"正在思考…")→ completed(绿色"执行完成",running 行标记 completed)/ failed(红色"执行失败",running 行标记 failed)。
开关:channel_feishu_trace_enabled(默认 True)控制全局;binding config_json 中 trace_enabled: false 可细粒度关闭单个绑定。is_feishu_trace_enabled(binding) 统一判定。

测试

  • 全量 1479 项后端测试通过
  • 前端 66 项测试通过,TypeScript 编译 + Vite 构建通过
  • ruff 检查干净(新文件零 error)
  • 新增 20 项单测覆盖 EventLog sink、FeishuTraceStreamer 全生命周期、adapter create_card/update_card

@fadeoreo

Copy link
Copy Markdown
Collaborator

markdown_render.py:597
上次的超长代码单行问题仍然存在。中间分片会裸发成普通文本,补围栏后的 chunk 还可能超过渠道长度限制。

feishu_trace.py:171 / event_log.py:44
每次 trace 事件会在 AgentLoop 主执行路径里同步调用飞书 PATCH API,单次超时最长 15 秒。飞书网络异常时,多次事件可能反复阻塞 15 秒,显著拖慢甚至卡住整个对话和会话锁。“捕获异常”只能防止报错,不能隔离网络延迟。应改成后台队列/worker 异步更新,或者至少使用独立的极短超时与合并更新机制。

…ace I/O to background worker

- markdown_render: overlong single-line code inside fenced blocks now
  wraps each hard-cut chunk in open/close fences with language tag,
  ensuring all chunks render as code and content is fully recoverable
- feishu_trace: all HTTP I/O (create_card/update_card) moved to a
  background worker thread with a task queue; on_event never blocks
  the AgentLoop main thread; finish/abort join worker with 8s timeout
- handle race where finish/abort is called before card creation
  completes via _final_state flag and _draining pattern
- add regression tests for content recovery and async edge cases
@skywclouds

Copy link
Copy Markdown
Contributor Author

PR 评论回复:
感谢 review,两个问题已修复并推送(d718e94):

1. 长单行代码切分后中间 chunk 丢失围栏

问题:split_markdown_by_lines 在围栏代码块内遇到超长单行时,硬切产生的中间片段以纯文本形式发出,没有包裹围栏,导致这些片段被渲染为普通文本而非代码,内容也无法通过 parse_markdown 恢复。

修复:当 fence_state 处于激活状态时,每个硬切 chunk 都会包裹对应的 open/close 围栏(含语言标识)。例如:

xxxxxxxxxx...(100字符xxxxxxxxxx...(100字符修复后验证500 字符的单行代码被切分为 7 每段围栏成对`parse_markdown` 可完整恢复全部 500 字符内容 ` ``` `、`~~~`、带语言标识和无语言标识的围栏均添加了回归测试### 2. Feishu PATCH 同步调用阻塞 AgentLoop 主线程

**问题**`FeishuTraceStreamer`  `start()``on_event()``finish()``abort()` 均在 AgentLoop 主线程同步执行 HTTP 调用`create_card` / `update_card`),超时上限 15s会阻塞 turn 执行和 session **修复**引入后台 worker 线程 + 任务队列所有 HTTP I/O  worker 中执行- **`on_event`**仅做内存中的行累积 + 节流判断满足条件时入队 `_PatchCardTask`立即返回绝不阻塞
- **`start`**入队 `_CreateCardTask`立即返回
- **`finish` / `abort`**设置 `_final_state`入队最终状态 patch 任务然后 join worker超时 8s确保最终卡片更新被尝试

同时处理了竞态`finish`/`abort` 在卡片创建完成前被调用时`_do_create_card` 会检测 `_final_state` 并自动补发最终状态更新worker 使用 `_draining` 标志确保动态入队任务如卡片创建后触发的 patch在哨兵之前被处理新增 2 个异步边界测试卡片未创建时 `on_event` 不阻塞`finish` 先于卡片创建时仍发送最终状态全部 1484 后端测试 + 66 前端测试通过ruff 检查通过

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.

2 participants