Skip to content

feat: 飞书历史图片重构 — 话题首条带标签走多模态, 纯历史走文本路径 - #242

Merged
lishuceo merged 9 commits into
mainfrom
feat/claude-session-3f3f5f
May 24, 2026
Merged

feat: 飞书历史图片重构 — 话题首条带标签走多模态, 纯历史走文本路径#242
lishuceo merged 9 commits into
mainfrom
feat/claude-session-3f3f5f

Conversation

@lishuceo

Copy link
Copy Markdown
Owner

Summary

解决飞书机器人长期存在的两个图片处理问题:

  • 话题首条图片被混淆:用户在话题开头发图配文"看这张图",后续别人在群里发了其他图(简历/表情等),agent 无法区分,常常分析错对象。本话题开头就是这个 bug。
  • 历史图片污染多模态上下文:纯历史图片占用大量 token 预算,即使本轮对话与它们无关。

实施 方案 1+3:

  • 当前消息图 / 引用消息图 / 话题首条图 → 保留为多模态图片,但加文本标签让 agent 区分来源
  • 纯历史图片 → 落盘后只给文本路径提示,让 agent 用 Read 工具按需读取

关键改动

  • ImageAttachment 新增 label?: string,buildMultimodalPrompt 在带 label 的图片前插 `[图片说明: ]` 文本块
  • 新增 fetchTopicRootImages: 用 threadId 单独 getMessageById 取根消息(进程内 LRU 缓存)
  • 新增 formatHistoryImageHints: 渲染 `[历史聊天图片]` 文本提示
  • downloadHistoryImages 不再返回 base64,仅返回 paths
  • 三种标签字符串:
    • 用户当前消息的图片
    • 用户引用的消息中的图片
    • 话题首条消息的图片

Test plan

  • npx vitest run — 1452 passed (新增 multimodal-image-labels 5 例 + lazy-history-images 拓展)
  • npm run typecheck — green
  • 部署后端到端验证:话题首条发图 + 后续无关图 + @bot 提问,确认 agent 回答对应到首条图

🤖 Generated with Claude Code

lishuceo and others added 6 commits May 23, 2026 13:22
历史消息中 interactive 类型卡片被白名单 drop,导致 bot 拉不到话题开头的卡片内容(如 SpaceX/XOVR 分享卡)。

- 新增 formatInteractiveCard():递归解析卡片 header.title + elements (div/markdown/note/column_set/action),未知 tag 走 content 字段兜底
- client.ts fetchRecentMessages 白名单加入 interactive,parsing 分支调用 formatInteractiveCard
- message-parser.ts formatMergeForwardSubMessage interactive 分支同步切换

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
覆盖 header/div/fields/note/column_set/action 已知 tag,以及第三方卡片未知 tag 的兜底,共 12 个用例。同步更新 merge-forward.test.ts 的 interactive 用例。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ImageAttachment 加可选 label 字段,buildMultimodalPrompt 在每张带 label
的图片前插入 [图片说明: <label>] 文本块,让 agent 区分图片来源(当前消息/
话题首条/引用消息),防止混淆为同一组图。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
覆盖 5 个场景:全部带 label / 无 label / 混合 / 与 documents 顺序 / 无图片。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
解决两个生产问题:
1. 话题首条图片(用户提问的核心图)与后续无关图片混在一起,agent 经常分析错对象
2. 纯历史图片占用大量多模态 token 预算,污染上下文

改动:
- 新增 fetchTopicRootImages: 用 threadId 单独 getMessageById 取话题根消息,
  带 label='话题首条消息的图片' 注入多模态。进程内 LRU 缓存(max 100)避免
  resume 时重复 fetch。
- 新增 formatHistoryImageHints: 把纯历史图片落盘路径渲染成 [历史聊天图片]
  文本提示,前置到 prompt,让 agent 用 Read 工具按需读取。
- downloadHistoryImages 重构: 不再返回 base64,仅返回 historyImagePaths +
  lazyHints。话题首条由 fetchTopicRootImages 处理,这里跳过去重。
- HistoryResult: 删除 images/savedImagePaths,新增 topicRootImages +
  historyImagePaths。
- buildHistoryContext: 并行执行 fetchTopicRootImages + downloadHistoryImages
  + downloadHistoryFiles。
- 合并点: 当前消息图打默认 label='用户当前消息的图片',话题首条合入多模态,
  纯历史图走文本前置 + restartImagePaths 兜底。Resume 时跳过两类注入。
- injectQuotedMessage 引用图带 label='用户引用的消息中的图片'。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- lazy-history-images: 改用 historyImagePaths/lazyHints 字段,新增 topicRoot
  排除用例 + saveMessageFileToCache 失败兜底用例。
- inject-quoted-message: 断言引用图片带 label='用户引用的消息中的图片'。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 4m 54s —— View job


Claude Code Review — ⚠️ Issues Found

  • Read CLAUDE.md and PR context
  • Check previous review comments (none from claude[bot])
  • Read full diff vs origin/main
  • Review changed files in depth
  • Post inline comments
  • Post summary

整体方向赞同:把"话题首条/引用/当前"图片带标签走多模态、把纯历史图片落盘 → 文本路径 hint,是合理的分流方案。fetchTopicRootImages 单独走 getMessageById + LRU 来稳住话题根,比依赖滑动历史窗口靠谱。formatInteractiveCard 的兜底递归 + depth 限制也很周到。测试覆盖(5 例 multimodal label + lazy-history 扩展 + 11 例 card 解析)覆盖到位。

关键问题

🟡 historyImagePaths 同时包含话题首条 savedPaths(confidence: 85) — src/feishu/event-handler.ts:1870
话题首条图片既被注入为多模态(带"话题首条消息的图片"标签),其落盘路径又被合并进 historyImagePaths,在非 resume 分支被 formatHistoryImageHints 渲染成"历史消息中的图片未自动展开,如需查看请使用 Read 工具读取"。同一张图既"已展开+带标签",又被告知"未展开",自相矛盾,可能诱导 agent 多调一次 Read。注释 "纯历史图片转成文本提示" 也与代码不符。建议把 restart 兜底用途与 hint 展示用途的路径数组拆开。

🟡 Resume 分支没给当前消息图片打标签(confidence: 80) — src/feishu/event-handler.ts:2451-2456:3048-3053
images.map(... label ?? '用户当前消息的图片') 只在 else(非 resume)分支执行;而 injectQuotedMessage 在 if/else 之外始终给引用图片打 label。Resume + (当前图 + 引用图) 场景下:当前图无 label / 引用图有 label,形成"半标签"不对称——而这恰是 PR 想解决的"长对话中区分图片来源"的核心场景。建议把 label 注入逻辑移出 if/else。

其他观察(非阻塞)

  • _putTopicRootCache LRU 上限 100、map insertion-order 实现正确;空结果也被缓存以避免反复 fetch ✓
  • fetchTopicRootImages 失败(throw)路径不缓存 → 永久失败的 thread 每轮都会重试一次,量级可接受
  • formatInteractiveCardheader.title.content 是非字符串的退化情况会触发 try/catch fallback 返回 "[卡片消息 - 解析失败]",UX 上略糙但不崩溃
  • 测试中用 @ts-nocheck 处理 multimodal-label 用例属合理取舍
  • getMessageById 串行先于 Promise.all 是必要的(dedup 依赖 rootMessageId

修复上面两点后即可合并。
· Branch: feat/claude-session-3f3f5f

downloadHistoryFiles(messages, parentMsgCount),
]);
const fileTexts = [...historyFiles.fileTexts, ...imagesResult.lazyHints];
const historyImagePaths = [...topicRoot.savedPaths, ...imagesResult.historyImagePaths];

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.

🟡 话题首条图片同时走多模态 + 历史文本提示,信号自相矛盾(confidence: 85)

这里把 topicRoot.savedPaths 合并进 historyImagePaths

const historyImagePaths = [...topicRoot.savedPaths, ...imagesResult.historyImagePaths];

下游 executeClaudeTask / executeDirectTask 的非 resume 分支会同时

  1. topicRootImages 当作多模态注入,带标签 [图片说明: 话题首条消息的图片]
  2. historyImagePaths包含同一张图的 savedPath)渲染成 formatHistoryImageHints 的提示 —— 文案是:

    [历史聊天图片] 历史消息中的图片未自动展开,已落盘到本地,如需查看请使用 Read 工具读取

也就是说,agent 一边能直接看到话题首条的图片,一边又被告知"这张图未自动展开,请用 Read 读取"。这与 line 2461 注释 "纯历史图片转成文本提示" 自相矛盾,且会诱导 agent 浪费一次 Read 调用读重复图片。

建议把"用于 restartImagePaths 兜底"和"用于文本 hint 展示"两条用途分开:

  • historyImagePaths(hint 用)只包含纯历史路径(不含话题首条);
  • 话题首条的 savedPaths 单独放在另一个字段(如 topicRootSavedPaths),只用于 push 到 restartImagePaths,不进 hint。

或者在 formatHistoryImageHints 入参上排除掉 topicRoot.savedPaths

Fix this →

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.

⚠️ 仍未修复 — line 1877 仍是 const historyImagePaths = [...topicRoot.savedPaths, ...imagesResult.historyImagePaths];,话题首条图片的 savedPath 仍会被 formatHistoryImageHints 渲染为 "未自动展开" 文本提示(line 2470 / 3067),与同一张图片以 [图片说明: 话题首条消息的图片] 直接多模态注入形成自相矛盾。

Comment on lines 2451 to 2467
} else {
// 非 resume:正常合并历史文件
// 合并历史消息中的图片
if (history.images && history.images.length > 0) {
images = [...(history.images), ...(images ?? [])];
// 当前消息图片打标签(用户主动发的图片)
if (images?.length) {
images = images.map(img => ({ ...img, label: img.label ?? '用户当前消息的图片' }));
}
// 话题首条图片(自带 label)合并进多模态
if (history.topicRootImages && history.topicRootImages.length > 0) {
images = [...(images ?? []), ...history.topicRootImages];
}
// 纯历史图片转成文本提示,前置到 effectivePrompt(避免污染多模态)
if (history.historyImagePaths && history.historyImagePaths.length > 0) {
const hint = formatHistoryImageHints(history.historyImagePaths);
if (hint) {
effectivePrompt = hint + '\n\n---\n\n' + effectivePrompt;
}
}

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.

🟡 Resume 分支没有给当前消息的图片打标签,与引用图片混合时出现"半标签"状态(confidence: 80)

标签注入只在 else(非 resume)分支内:

} else {
  if (images?.length) {
    images = images.map(img => ({ ...img, label: img.label ?? '用户当前消息的图片' }));
  }
  ...
}

injectQuotedMessageif/else 之外调用(line 2481),始终会给引用图片打 '用户引用的消息中的图片'。结果:

模式 当前消息图片 引用图片
非 resume [图片说明: 用户当前消息的图片] [图片说明: 用户引用的消息中的图片]
resume ❌ 无 label [图片说明: 用户引用的消息中的图片]

在 resume + 同时带当前图 + 引用图的场景下,agent 收到的 content blocks 是 [image, text(引用 label), image, text(main)] —— 第一张图没有任何说明,与第二张明显不对称。

这恰好是 PR 主旨想解决的场景(长对话区分图片来源),却被 resume 分支绕过了。

建议把标签注入移出 if/else(在 buildHistoryContext 调用后立即执行),或在 resume 分支里也加一份。executeDirectTask line 3050-3052 有同样问题。

Fix this →

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.

⚠️ 仍未修复 — images.map(... label ?? '用户当前消息的图片') 仍只在 else(非 resume)分支内:executeClaudeTask line 2461-2463、executeDirectTask line 3058-3060。而 injectQuotedMessage 始终给引用图片打 label。Resume + 当前图 + 引用图场景下仍是 "半标签" 不对称(当前图无 label,引用图有 label)。

@github-actions

github-actions Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 2m 9s —— View job


Claude Code Review — ⚠️ Issues Found (Previous Comments Persist)

  • Read CLAUDE.md and PR context
  • Check previous review comments (2 prior inline comments — both still unresolved)
  • Read full diff vs origin/main
  • Review changed files in depth
  • Reply to previous inline comments (noted persistence)
  • Post summary

状态

两条上一轮的 review comment 在 force-push / 新提交后 均未修复,已在原 thread 回复"仍未修复"。本轮没有新增 inline comment(按规则避免重复)。

持续的问题(与上轮一致)

🟡 historyImagePaths 仍合并话题首条 savedPathssrc/feishu/event-handler.ts:1877(confidence: 85)

const historyImagePaths = [...topicRoot.savedPaths, ...imagesResult.historyImagePaths];

话题首条图片既以 [图片说明: 话题首条消息的图片] 直接走多模态(line 2466 / 3063),其落盘路径又被 formatHistoryImageHints 渲染为 历史消息中的图片未自动展开,如需查看请使用 Read 工具读取(line 2470 / 3067)。同一张图既"已展开+带标签"又"未展开",agent 会被诱导多调一次 Read 读取重复图片。建议把 hint 路径与 restart 兜底路径拆开。

🟡 Resume 分支无当前图 label,形成"半标签"不对称src/feishu/event-handler.ts:2461-2463:3058-3060(confidence: 80)
当前图打 label 的代码 images.map(img => ({ ...img, label: img.label ?? '用户当前消息的图片' })) 只在非 resume 分支内执行。injectQuotedMessage(line 2489 / 3082)在 if/else 之外,引用图永远带 '用户引用的消息中的图片' label。Resume + 当前图 + 引用图场景下出现「当前图无 label / 引用图有 label」的不对称——而这正是 PR 想解决的长对话区分图片来源的核心场景。建议把 label 注入移出 if/else。

其他观察(与上轮一致,非阻塞)

  • formatInteractiveCard 兜底递归 + depth 限制完善 ✓
  • _putTopicRootCache LRU 大小 100、空结果缓存避免重复 fetch ✓
  • fetchTopicRootImages throw 路径不缓存 → 永久失败的 thread 每轮重试一次(可接受)
  • 测试覆盖(multimodal label 5 例 + lazy-history 扩展 + card 解析)到位 ✓

修复以上两点后即可合并。
· Branch: feat/claude-session-3f3f5f

lishuceo and others added 2 commits May 24, 2026 20:17
Deep-review (#242) 高置信度问题修复:

1. fetchTopicRootImages 早返回路径不写缓存,transient 错误每轮 resume
   都重新打 API。引入 cacheEmpty() 哨兵,所有失败/空/无图分支都缓存。
2. post-body 解析用 typeof key === 'string' 守卫,避免 image_key=null
   /数字时把非字符串塞进 downloadMessageImage。Object.values fallback
   改为只取 zh_cn/en_us/ja_jp 显式 key,不再吞 title 等无关字段。
3. imageKeys 截断到 MAX_HISTORY_IMAGES (5),防御异常 post 携带巨量 img
   把多模态 payload 撑爆。
4. 缓存 hit 时把 '' rootMessageId 哨兵转为 undefined,语义对外清晰。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
覆盖 deep-review 指出的测试缺口:
- msg_type=image / msg_type=post 解析
- imageKeys 上限截断
- 非 string image_key 防御
- LRU 缓存命中不重 fetch
- 哨兵机制:空结果/transient 错误/无图都不再 hammer API
- 部分下载失败仍返回成功部分

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 1m 25s —— View job


Claude Code Review — ⚠️ Issues Found (Previous Comments Still Persist)

  • Read CLAUDE.md and PR context
  • Check previous review comments (2 prior inline + 2 follow-up "仍未修复" — all still unresolved)
  • Read current diff vs origin/main
  • Verify the two unresolved issues' current state (both confirmed still present)
  • Skip duplicate inline comments (per dedup rule)
  • Post summary

状态

两条上一轮的 review comment(以及上上轮)在最新 push 后均仍未修复。本轮按去重规则未新增 inline comment(避免在同一 thread 上堆叠相同内容)。

持续的问题(与前两轮一致,源码原地未变)

🟡 historyImagePaths 仍合并 topicRoot.savedPathssrc/feishu/event-handler.ts:1899(confidence: 85)

const historyImagePaths = [...topicRoot.savedPaths, ...imagesResult.historyImagePaths];

后续在 executeClaudeTask line 2491-2496 / executeDirectTask line 3088-3091 调用 formatHistoryImageHints(history.historyImagePaths),会把话题首条图片的 savedPath 渲染成 历史消息中的图片未自动展开,如需查看请使用 Read 工具读取,而同一张图已经以 [图片说明: 话题首条消息的图片] 走了多模态(line 2487-2489 / 3084-3086)。同一张图既"已展开+带标签"又"未展开+请用 Read 读取",agent 会被诱导对重复图片多调一次 Read。

修复方向:把 hint 路径与 restart 兜底路径拆开,例如在 buildHistoryContext 返回 topicRootSavedPaths 单独字段,hint 只渲染 imagesResult.historyImagePaths,restart 兜底仍合并两者。

🟡 Resume 分支无当前消息图 label,与引用图形成"半标签"不对称src/feishu/event-handler.ts:2483-2485:3080-3082(confidence: 80)

} else {
  // 当前消息图片打标签(用户主动发的图片)
  if (images?.length) {
    images = images.map(img => ({ ...img, label: img.label ?? '用户当前消息的图片' }));
  }
  ...
}

这段只在 else(非 resume)分支执行。而 injectQuotedMessage(line 2511 / 3104)在 if/else 之外调用,引用图永远带 '用户引用的消息中的图片' label。Resume + 当前图 + 引用图场景下出现「当前图无 label / 引用图有 label」的不对称——而这恰是 PR 想解决的"长对话中区分图片来源"核心场景。

修复方向:把 label 注入移出 if/else(在 buildChatHistoryContext 返回后立即执行),或者在 resume 分支里也加一份。

其他观察(与前两轮一致,非阻塞)

  • formatInteractiveCard 兜底递归 + depth 限制完善 ✓
  • _putTopicRootCache LRU 大小 100、空结果缓存避免重复 fetch ✓
  • fetchTopicRootImages throw 路径不缓存 → 永久失败的 thread 每轮重试一次(可接受)
  • 测试覆盖(fetch-topic-root-images.test.ts 新增 + multimodal label 5 例 + lazy-history 扩展 + card 解析)到位 ✓

修复以上两点后即可合并。

· Branch: feat/claude-session-3f3f5f
·

@lishuceo
lishuceo merged commit 8e6eeff into main May 24, 2026
4 checks passed
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