qwen-code 钉钉富文本多图消息完整送达方案:从 richText 回调到 Web Shell 回放的实现指南
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
本指南基于仓库内实现计划文档 docs/plans/dingtalk-richtext-multi-image.md 及其配套设计文档 docs/design/dingtalk-richtext-multi-image.md 编写,并对照
packages/channels下的实际源码与测试进行印证。
在 qwen-code 的 DingTalk 频道场景中,用户在一句消息里同时发送多张图片时,旧实现只会把第一张图传给多模态模型,其余图片全部丢失。本文以官方实现计划为主线,完整讲解"一次钉钉richText回调中的每张图片如何被下载、归一化、经 Agent Client Protocol(ACP)发送给模型、并由 daemon 持久化到会话中供 Web Shell 回放"的整条链路:包括四个层次的接口契约、顺序保证、兼容性策略、失败语义,以及每项任务的 TDD 步骤与验证命令。读完本文,你可以掌握该方案的架构脉络,并能够在自己的频道适配器中复用这套"有序图片集合"的桥接模式。
背景与问题:一张richText回调里的多张图片为何只保留第一张
DingTalk(钉钉)机器人通过 Stream 模式接收消息时,一条富文本消息的content.richText数组中可以包含多个picture类型的 part。设计文档 docs/design/dingtalk-richtext-multi-image.md 明确指出旧实现的缺陷:
The DingTalk adapter extracts every part into
downloadCodes[], but the processing path callsattachMediaonly fordownloadCodes[0]. Consequently the model and daemon-backed Web Shell receive only the first image.
也就是说,适配器其实已经把所有图片 part 提取成了downloadCodes: string[]数组,但后续处理路径只针对downloadCodes[0]调用了attachMedia。最终用户多模态模型和 daemon 支撑的 Web Shell 会话都只能看到第一张图。
方案的目标(Goal)与整体架构(Architecture)在原计划文档中定义如下:
- 目标:将一条 DingTalk
richText回调中的每一张图片都送达多模态模型,并持久化每一个图片引用供 Web Shell 回放。 - 架构:把旧的单图输入与结构化图片附件统一归一化为"有序的桥接层图片数组"(ordered bridge-level image array)。DingTalk 下载每一个图片 part,ACP 为每个图片块发出一条内容块,daemon 在提交单条 prompt 之前上传每张图片。
- 技术栈:TypeScript、Vitest、DingTalk Stream 适配器、Agent Client Protocol(ACP)、Qwen daemon 会话附件 API。
全局约束:四条不可触碰的红线
实现计划在正文开始前列出了四条全局约束,任何实现细节都不得违反:
- 保留
imageBase64与imageMimeType的兼容性:所有现存适配器与桥接调用方仍可用旧的单图字段,不能因为新增images数组就破坏它们。 - 保留源顺序:图片顺序必须从
content.richText[]一路贯穿到模型 prompt 与 Web Shell transcript,不允许中途重排。 - 不改动活跃 turn 期间独立消息的采集逻辑:本方案只解决"一条回调内的多张图片",不改变 turn 进行中陆续到达的独立消息如何被引导或聚合。
- 不引入任何新依赖或新配置:纯代码层面的行为修正。
配合设计文档 docs/design/dingtalk-richtext-multi-image.md 的兼容性说明还可以补充两点:无效的"部分遗留字段对"(只有imageBase64没有imageMimeType之类)与今天一样会被忽略;现有单图调用方的排序与行为保持不变。
四层数据流:从钉钉回调到 Web Shell 回放
整个方案可以拆成四个层次,每一层对应计划中的一个 Task。数据流的方向是:
DingTalk richText 回调 │ Task 1: DingtalkAdapter 逐个 downloadCode 调用 attachMedia ▼ Envelope.attachments[](有序、data-backed 图片附件) │ Task 2: ChannelBase 归一化为 ChannelPromptImage[] ▼ ChannelAgentBridgePromptOptions.images[] ├─ Task 3: AcpBridge → 每张图一个 ACP image 内容块(在 text 块之前) └─ Task 4: DaemonChannelBridge → 每张图一次 session.uploadAttachment + 引用块下面按 Task 顺序展开,并结合当前仓库源码说明落地后的真实形态(从源码结构看,该计划中的改动已经在 packages/channels 中落地)。
Task 1:DingTalk 下载每一个图片 part(适配器层)
涉及文件:packages/channels/dingtalk/src/DingtalkAdapter.ts、packages/channels/dingtalk/src/DingtalkAdapter.test.ts
接口契约:
- 消费:按回调顺序消费
extractContent(data).downloadCodes: string[]。 - 产出:
Envelope.attachments,其中每个成功下载的图片对应一个>expect(downloadCodes).toEqual(['picture-1', 'picture-2']); expect(envelope.attachments).toEqual([ { type: 'image', data: Buffer.from([1]).toString('base64'), mimeType: 'image/png', }, { type: 'image', data: Buffer.from([2]).toString('base64'), mimeType: 'image/png', }, ]);- 运行测试并验证 RED:
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts -t "downloads every picture in one richText callback"预期失败原因:旧实现只请求了
picture-1,且只有一个附件。- 实现有序多下载:把"只取第一个"替换为顺序循环:
for (const downloadCode of content.downloadCodes) { await this.attachMedia( envelope, downloadCode, content.mediaType, content.fileName, content.placeholder, ); }关键点是:每个成功的图片下载都追加一个>
- 运行定向适配器测试验证 GREEN:
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts- 提交:
git add packages/channels/dingtalk/src/DingtalkAdapter.ts packages/channels/dingtalk/src/DingtalkAdapter.test.ts git commit -m "fix(channels): retain all DingTalk rich-text images"源码印证:当前 DingtalkAdapter.ts 中,
processMessage分支已经实现了上述循环——当content.downloadCodes.length > 0 && content.mediaType时,按回调顺序遍历所有downloadCode依次调用attachMedia,随后再处理quoted.media(回复中引用的图片)。而 attachMedia 对image类型会把下载到的字节转为base64并追加到envelope.attachments,MIME 类型取自下载响应头并以image/jpeg为兜底;非图片媒体则写入临时目录供 agent 读取。测试 DingtalkAdapter.test.ts 正是计划中的"downloads every picture in one richText callback"用例:它 mock 了fetch,记录两次downloadCode,最终断言下载顺序与两个附件的内容、顺序完全一致。Task 2:ChannelBase 承载有序图片集合(桥接契约层)
涉及文件:
packages/channels/base/src/ChannelAgentBridge.ts、packages/channels/base/src/ChannelBase.ts、packages/channels/base/src/ChannelBase.test.ts接口契约:
- 产出:
ChannelPromptImage { data: string; mimeType: string }与ChannelAgentBridgePromptOptions.images?: ChannelPromptImage[]。 - 兼容输入:
imageBase64?: string加imageMimeType?: string。
TDD 步骤:
- 写一个含两个>images: [ { data: 'first', mimeType: 'image/png' }, { data: 'second', mimeType: 'image/jpeg' }, ];
保留旧测试,但把断言改成同构的单元素
images形状。- 运行定向测试验证 RED:
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t "image"- 增加桥接图片类型与归一化逻辑:
export interface ChannelPromptImage { data: string; mimeType: string; } export interface ChannelAgentBridgePromptOptions { images?: ChannelPromptImage[]; imageBase64?: string; imageMimeType?: string; displayText?: string; }按"遗留字段优先、附件顺序随后"的次序构建
images并传给promptBridge.prompt;没有图片数据的附件继续保留原有的文件路径渲染行为。- 运行 ChannelBase 全部测试验证 GREEN,要求非图片附件行为零变化。
- 提交:
git add packages/channels/base/src/ChannelAgentBridge.ts packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts git commit -m "feat(channels): carry ordered prompt images"源码印证:接口定义与归一化逻辑已在 ChannelAgentBridge.ts 落地。
resolvePromptImages(options)是整条链路的"单一事实来源":images非空时优先采用,否则回退到imageBase64+imageMimeType组成的单元素数组,两者皆无则返回空数组;随后过滤掉缺data或mimeType的条目(一个畸形附件只会降级为该图不参与 prompt,与旧字段守卫行为一致),并统一清理 MIME:去掉参数段(如image/png; charset=binary)、转小写,同时把非标准别名image/jpg归一化为image/jpeg——这与 daemon 附件存储自身的命名规则保持对齐。Task 3:ACP 将每张图片作为原生内容块发送(模型侧)
涉及文件:
packages/channels/base/src/AcpBridge.ts、packages/channels/base/src/AcpBridge.test.ts接口契约:
- 消费:
ChannelAgentBridgePromptOptions.images,保留遗留单图回退。 - 产出:ACP prompt 内容,其中每个
{ type: 'image', data, mimeType }块都位于 text 块之前。
TDD 步骤:
- 用两张字面量图片调用
prompt,断言连接层收到:
prompt: [ { type: 'image', data: 'first', mimeType: 'image/png' }, { type: 'image', data: 'second', mimeType: 'image/jpeg' }, { type: 'text', text: 'describe both' }, ];- 验证 RED(旧实现未消费
images):
cd packages/channels/base && npx vitest run src/AcpBridge.test.ts -t "multiple images"- 实现图片迭代:用遗留字段对作为回退归一化
options.images,先 push 每个图片块,再 push text 块;不改动 ACP 元数据。 - 验证 GREEN,要求既有单图行为不回归。
- 提交:
git add packages/channels/base/src/AcpBridge.ts packages/channels/base/src/AcpBridge.test.ts git commit -m "feat(channels): send all prompt images over ACP"源码印证:AcpBridge.ts 的
prompt实现正是"先图后文":for (const image of resolvePromptImages(options))逐张 push{ type: 'image', data, mimeType },随后才 push{ type: 'text', text },并把displayText等元信息放入_meta。这保证了多模态模型收到的是完整的、按原始顺序排列的视觉输入。Task 4:Daemon 持久化每一张图片供 Web Shell 回放(会话侧)
涉及文件:
packages/channels/base/src/DaemonChannelBridge.ts、packages/channels/base/src/DaemonChannelBridge.test.ts接口契约:
- 消费:有序的
ChannelAgentBridgePromptOptions.images(含遗留回退)。 - 产出:每张图片一次
session.uploadAttachment调用 + 一个附件引用 prompt 块。
TDD 步骤:
- 扩展失败测试:传两张图,返回两个不同的附件引用,断言上传顺序与如下 prompt:
prompt: [ { type: 'image', attachmentId: 'image.png', mimeType: 'image/png', size: 12 }, { type: 'image', attachmentId: 'image-2.jpeg', mimeType: 'image/jpeg', size: 13, }, { type: 'text', text: 'describe' }, ];- 验证 RED(旧实现只上传单图):
cd packages/channels/base && npx vitest run src/DaemonChannelBridge.test.ts -t "stores channel images"- 实现有序上传与唯一命名:遍历所有归一化图片,生成确定性文件名(
image.png、image-2.jpeg、…),逐个await uploadAttachment,在 text 块之前 push 所有返回的引用。 - 验证 GREEN,prompt 中包含全部已持久化的附件引用。
- 提交:
git add packages/channels/base/src/DaemonChannelBridge.ts packages/channels/base/src/DaemonChannelBridge.test.ts git commit -m "feat(channels): persist all channel images in daemon sessions"源码印证:DaemonChannelBridge.ts 的实现比计划更进一步:使用
Promise.allSettled将多张图片的上传"扇出"并行执行(注释明确说明命名按 index 消歧、prompt 顺序来自数组顺序,因此无需串行化上传本身);每个图片先经channelImageName(image.mimeType, index)生成image.png、image-2.jpeg这类确定性名称,再通过decodeChannelImage校验 base64 数据(超出 daemon 附件大小上限会被跳过),最后调用session.uploadAttachment上传。MIME 子类型无法识别或解码失败时只会打印告警并跳过该图,而不会让整轮 turn 失败——这与计划中"单图失败不阻断整轮"的失败语义一致;若 daemon 上传整体失败,则维持既有的 prompt 失败语义,绝不在图片集合不完整的情况下静默提交。Task 5:端到端验证完整行为
涉及文件:仅当验证暴露缺陷时才修改上述文件;运行证据记录在最终交接与 Issue/PR 文本中,不提交凭据或回调负载。
验证命令(从各包目录运行):
npm run build npx tsc --noEmit -p packages/channels/base/tsconfig.json npx tsc --noEmit -p packages/channels/dingtalk/tsconfig.json cd packages/channels/base && npx vitest run src/ChannelBase.test.ts src/AcpBridge.test.ts src/DaemonChannelBridge.test.ts cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts预期全部命令退出码为 0。
随后是运行时验证:
- 重载频道 worker:
npm run dev -- channel reload,再用npm run dev -- channel status确认 DingTalk worker 处于运行状态。 - 执行五图 E2E:把五张"手"图片作为一条 DingTalk 消息一起发出,逐一验证:
- 恰好接受一个 DingTalk 回调;
- 完成五次媒体下载;
- 五次 daemon 附件上传均返回 HTTP 201;
- 持久化的用户 turn 中按顺序出现五个
attachmentReferences; - Web Shell 展示五张图片预览;
- 模型正确报告"五只手"。
- 自审最终 diff:不带任何过滤地阅读
git diff HEAD^与全部未跟踪文件,确认补丁中没有密钥、回调负载、无关的 lockfile 改动或独立消息缓冲行为的变更。
失败语义与顺序保证的设计权衡
设计文档 docs/design/dingtalk-richtext-multi-image.md 对失败行为做了明确定义,这在实际联调中非常重要:
- DingTalk 媒体下载保持顺序执行,延续回调顺序;某个下载失败时沿用既有的"per-media 告警"行为,且不会阻止其他成功下载的图片进入该 turn。
- daemon 上传失败维持现有 prompt 失败语义:turn 不会带着静默缺失的图片集合被提交——要么全部图片就位,要么这轮 prompt 明确失败,避免模型基于不完整视觉输入给出错误结论。
- 从源码看,
resolvePromptImages对畸形条目的过滤、DaemonChannelBridge对超限/未知 MIME 的跳过,都是"按图降级"而非"整轮降级"。
顺序保证则贯穿四层:适配器按
richText顺序下载 →ChannelBase按"遗留字段优先、附件顺序随后"归一化 →AcpBridge按数组顺序推入内容块 →DaemonChannelBridge按数组顺序命名、上传并放置引用。用户看到的第一张图,就是模型看到的第一张图,也是 Web Shell 里渲染的第一张图。测试矩阵与验收标准
计划与设计文档共同定义了五类测试,构成完整的兼容性防线:
- DingTalk 适配器:一个含多个 picture part 的
richText回调下载每个 code,产出有序图片附件。 - ChannelBase:遗留单图输入与结构化图片输入都能归一化为有序桥接图片集合,且不丢弃后面的附件。
- ACP 桥接:每张图都成为 text 块之前的 ACP image 内容块。
- Daemon 桥接:每张图都被上传、在 prompt 中被引用,从而可被会话持久化与 Web Shell 回放。
- 既有单图测试保持绿色:证明兼容性没有回退。
最终验收标准(Acceptance criteria)非常直观:一次在一条 DingTalk 消息中发送五张图片,daemon 会话 turn 中产生五个附件引用,Web Shell 显示五张图片,所选多模态模型按原始顺序收到全部五张图。
总结
这条"钉钉富文本多图完整送达"方案的价值在于:它以最小侵入(零新依赖、零新配置)修复了一个真实的用户可见缺陷,并把"多图"作为一等公民贯穿了适配器、桥接层、ACP 与 daemon 会话附件体系。计划文档提供的逐任务 TDD 步骤(RED → GREEN → commit)与全局约束,是理解该改动设计意图的最佳入口;而
resolvePromptImages的归一化逻辑、AcpBridge的"先图后文"内容块编排、DaemonChannelBridge的并行上传与确定性命名,则是这套方案在 packages/channels 源码中的具体落地形态。如果你正在为自己的频道适配器增加多图能力,这套"有序图片集合 + 遗留字段回退 + 分桥接层消费"的契约设计可以直接复用。【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.
项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考