21 KiB
出站图片原生呈现落地方案
状态:已实施;九渠道真机验收通过
日期:2026-08-24
关联:Issue #36、ADR-0001:Semantic Core with Native Channel Adapters、渠道原生能力建设方案
1. 结论
本方案不新建一套“媒体消息框架”,而是在现有出站产物链路上增加一种呈现选择:
- Agent 仍然只通过
dsh_im_return_file返回产物,图片也是OutboundArtifact,不增加return_image工具。 - 语义核心继续负责产物绑定、物化、校验、释放、回执和失败语义。
- 共享投递逻辑根据已物化产物的
mediaType识别image/*。 - 渠道已有
sendImage时调用渠道原生图片 API;没有独立图片 API、但现有附件本身就能原生预览的渠道继续调用sendFile。 - 原生图片发送被渠道明确拒绝时,自动降级为现有
sendFile;发送结果不确定时禁止再次发送,避免重复消息。
最小的新增抽象只有两项:
- 一个共享的产物投递函数,用来替代各 Bridge 中重复的
#deliverArtifacts循环; - 各渠道现有 API/Runtime 对象上的可选
sendImage方法。
不引入新的产物类型、基类、注册表、配置开关、回执版本或本地图片格式白名单。
1.1 实施结果(2026-08-24)
本方案已按上述最小边界落地:
- 新增
src/channels/shared/semantic/artifact-delivery.mjs,统一负责image/*选择、确定性失败降级、artifact-delivery-uncertain/Abort 防重复、回执合并和 Artifact 释放。 - 共享
TextHarnessBridge及飞书、微信、钉钉、企业微信、QQ Bridge 均改用该函数,不再各自维护图片识别与降级循环。 - 飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 接入各自原生图片发送;Slack、Discord 继续复用一次原生附件上传并显示图片预览。
dsh_im_return_file、OutboundArtifact、DeliveryReceiptv1 和现有 Bot 配置结构均保持不变。npm run check通过:1089/1089 测试通过,构建和发布包校验通过;独立代码复审未发现剩余 P0–P2 问题。
2. “语义核心 + 渠道原生适配”在本方案中的含义
| 层次 | 现有代码 | 本方案中的职责 |
|---|---|---|
| 语义入口 | src/channels/shared/semantic/artifact.mjs 中的 dsh_im_return_file |
表达“把这个已生成产物交付给用户”,不表达飞书 image_key、Telegram photo 等渠道协议 |
| 语义核心 | OutboundArtifact、materializeOutboundArtifact、releaseOutboundArtifact |
校验 Session/Turn 归属,生成不可变字节快照,提供文件名、MIME、交付键并管理生命周期 |
| 共享交付语义 | src/channels/shared/semantic/delivery.mjs |
统一成功、拒绝、失败、结果不确定等回执语义 |
| 渠道原生适配 | 各渠道 sendFile,以及本方案新增的可选 sendImage |
把同一个图片产物翻译成渠道的原生图片上传与消息请求 |
| Bridge | 各渠道 Bridge、text-harness-bridge.mjs |
只注入目标、回复上下文和发送函数,不再各自维护图片识别与降级规则 |
也就是说,核心只知道“这是一个 image/* 产物,优先以图片呈现”;渠道适配器才知道它应当变成飞书的 image_key、Telegram 的 sendPhoto,还是 Discord 的图片附件。
这符合 ADR-0001 中的两个边界:公共语义只实现一次;供应商协议留在渠道边界。
3. 现状与缺口
3.1 已经具备、应直接复用的能力
dsh_im_return_file已经是受控的出站产物入口。OutboundArtifact已携带artifactId、deliveryKey、fileName、mediaType、size和字节快照。- MIME 推断已经覆盖 PNG、JPEG、GIF、WebP 等常见图片。
materializeOutboundArtifact、releaseOutboundArtifact已处理安全读取和生命周期。DeliveryReceiptv1 已能表示sent、rejected、failed、unknown。artifact-delivery-uncertain已表示“请求可能已经到达平台,但无法确认结果”。- 九个渠道都已有可工作的
sendFile路径,可作为统一降级路径。
3.2 当前问题
- 图片和普通文件最终都进入
sendFile,没有共享的“图片优先”选择规则。 #deliverArtifacts在共享 Bridge 及飞书、微信、钉钉、企业微信、QQ 等 Bridge 中重复,后续策略容易分叉。- 飞书已经有一段用于修复二维码的原生图片上传代码,但尚未成为通用产物发送能力。
- 其他渠道有原生图片 API 或图片消息类型,尚未接入当前产物链路。
4. 设计原则
4.1 必须保持的约束
- 图片仍然是 Artifact,不创建第二条“图片产物”生命周期。
- 不解析模型文本中的 Markdown 图片、URL 或本地路径来触发发送。
mediaType只作为呈现提示,不作为核心层的准入判断。- 图片格式、大小、权限等限制以渠道 API 的实际响应为准。
- 非图片产物的现有行为不得改变。
- 所有发送都继续服从 Abort、超时、幂等键和
artifact-delivery-uncertain语义。 - 原生呈现失败时必须提供明确的文件降级或失败通知,不能静默丢失。
4.2 本期不做
- 不实现完整的
SemanticDelivery、ChannelCapabilitySnapshot或ChannelAdapter类体系。 - 不新增
OutboundMedia、NativeMediaDriver、插件注册表或渠道配置开关。 - 不升级
DeliveryReceiptschema。 - 不做图片转码、压缩、缩略图、图集、富媒体排版或 caption 合并。
- 不要求各渠道支持完全相同的 GIF/WebP 动画效果;平台明确不支持时降级为文件。
- 不为 Slack、Discord 强行增加一个与现有附件上传完全相同的
sendImage别名。
5. 最小架构
Agent
│ dsh_im_return_file(path)
▼
OutboundArtifact 现有语义核心
│ materialize + mediaType
▼
deliverOutboundArtifacts() 新增的一处共享投递逻辑
│
├─ 非 image/* ───────────────────────────────► sendFile
│
└─ image/*
├─ 渠道提供 sendImage ─► sendImage
│ ├─ 成功 ───────► image receipt
│ ├─ 明确失败 ───► sendFile
│ └─ 结果不确定 ─► unknown receipt,不重发
│
└─ 渠道未提供 sendImage ────────────────► sendFile
(平台附件原生预览)
5.1 共享投递函数
新增:
src/channels/shared/semantic/artifact-delivery.mjs
建议导出一个函数,不定义基类:
deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId,
channelKey,
signal,
sendFile, // 必填:(file) => provider result
sendImage, // 可选:(file) => provider result
sendFailureNotice, // 必填:(artifact, error) => provider result
logger,
})
返回值保持贴近各 Bridge 现在需要的数据:
{
receipt,
userVisible,
artifactsSent,
artifactSendErrors,
}
该函数只集中现有重复逻辑:
- 逐个检查 Abort。
- 物化 Artifact。
- 选择
sendImage或sendFile。 - 生成、合并现有
DeliveryReceiptv1。 - 失败时调用渠道注入的安全通知函数。
- 在
finally中释放 Artifact。
Bridge 继续拥有会话目标、回复消息 ID、线程/话题、context token 等渠道上下文,通过闭包传入即可。例如:
return deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
channelKey: 'telegram',
signal: this.#signal,
sendImage: typeof this.#bot.sendImage === 'function'
? (file) => this.#bot.sendImage(target, file)
: undefined,
sendFile: (file) => this.#bot.sendFile(target, file),
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact.fileName, error, this.#descriptor),
),
logger: this.#logger,
});
typeof sendImage === 'function' 就是本期所需的最小运行时能力判断,不需要先建设一套能力注册系统。
5.2 呈现选择规则
const imagePreferred = file.mediaType?.startsWith('image/');
if (!imagePreferred || typeof sendImage !== 'function') {
return sendFile(file);
}
try {
return await sendImage(file);
} catch (error) {
if (signal?.aborted) throw error;
if (error?.code === 'artifact-delivery-uncertain') throw error;
return sendFile(file);
}
实际实现应记录最终使用的 presentation:
- 原生图片成功:
${channelKey}-image - 文件或附件成功:
${channelKey}-file - 失败:继续使用现有
createArtifactFailureReceipt - 多项合并:第一阶段保留已有 aggregate presentation 值,避免不必要的兼容变更
这里不设置 png/jpeg/... 白名单。比如某平台不接受 WebP,sendImage 应把平台的明确拒绝映射为确定性错误,共享层随后调用 sendFile;核心层无需追踪每个平台不断变化的格式矩阵。
5.3 降级与防重复规则
| 原生图片结果 | 是否调用 sendFile |
最终语义 | 原因 |
|---|---|---|---|
| 成功 | 否 | sent |
已原生展示 |
| 上传前或平台明确拒绝 | 是 | 以文件发送结果为准 | 已知没有产生图片消息,可以安全降级 |
artifact-delivery-uncertain |
否 | unknown |
图片消息可能已送达,再发文件会造成重复 |
| Abort | 否 | 向上抛出 | 尊重任务取消,不产生额外副作用 |
| 文件降级也失败 | 否 | 现有失败回执与安全通知 | 不再增加第三条路径 |
渠道适配器必须正确划分错误阶段:
- 图片字节校验、上传 URL 获取、明确的上传拒绝等,可映射为确定性失败。
- 最终消息请求发出后发生超时、连接中断或无法判断响应时,必须映射为
artifact-delivery-uncertain。
6. 各渠道落地方式
| 渠道 | 原生图片实现 | 复用现有代码 | 本期改动 |
|---|---|---|---|
| 飞书 | 上传图片取得 image_key,发送 msg_type: image |
bridge.mjs 中 #sendRepairQr 已有同类代码;FeishuChannel.sendFile 已有超时、跟踪与错误映射 |
在 FeishuChannel 增加 sendImage,抽取/复用图片上传发送步骤;普通产物由共享投递函数选择 |
| 微信 | 加密上传到 CDN,发送 image_item |
现有 weixin-api.mjs#sendFile 的鉴权、加密上传、context token、run id 和错误映射;腾讯上游已有图片发送实现 |
抽取上传公共部分,新增 sendImage,图片类型使用微信图片消息协议 |
| 钉钉 | 上传图片媒体,发送 sampleImageMsg |
dingtalk-api.mjs#sendFile 的 access token、媒体上传、目标选择和错误映射 |
增加 sendImage,只替换媒体类型和最终消息类型 |
| 企业微信 | uploadMedia(type: image) 后发送 image media message |
当前 Bridge 已用 SDK uploadMedia 和 sendMediaMessage 发送 file |
增加图片闭包或 Runtime 方法,分别传 image;不复制上传循环 |
调用 SDK sendImage |
已安装 QQ SDK 的图片方法;当前 Bridge 的超时、provider promise 跟踪和错误包装 | 在现有 Runtime/API 边界暴露 sendImage,保持和 sendFile 相同的消息上下文与错误语义 |
|
| Telegram | Bot API sendPhoto |
telegram-api.mjs 的请求、超时、reply/thread 参数和错误映射;Runtime 已有 sendFile |
增加 sendPhoto/sendImage 薄封装 |
Baileys image message { image, mimetype } |
whatsapp-runtime.mjs#sendFile 的稳定消息 ID、echo 抑制、超时和不确定错误处理 |
增加 sendImage,只改变消息 content 类型 |
|
| Slack | 继续现有文件上传;图片 MIME 会显示附件图片预览 | files.getUploadURLExternal、files.completeUploadExternal 现有链路 |
不增加 sendImage;补契约测试和真机验证即可 |
| Discord | 继续现有 multipart attachment;图片附件会内联显示 | discord-api.mjs 现有附件上传和 Content-Type |
不增加 sendImage;补契约测试和真机验证,不引入 embed |
说明:这里的“各渠道原生显示”指用户在原生客户端中看到图片,而不是要求所有平台都必须有一个名字叫 sendImage 的独立接口。Slack 和 Discord 的原生图片呈现方式就是图片附件预览,复用现有 sendFile 更简单,也避免对同一个 API 进行无意义的失败重试。
7. 代码改动清单
7.1 共享层
- 新增
src/channels/shared/semantic/artifact-delivery.mjs。 - 将
text-harness-bridge.mjs中现有#deliverArtifacts行为原样抽取到共享函数。 - 让飞书、微信、钉钉、企业微信、QQ 的重复循环逐步改为调用共享函数。
- Slack、Telegram、Discord、WhatsApp 已经走
text-harness-bridge.mjs的部分直接获得新策略,无需各自增加路由判断。 - 在
artifact.mjs中把工具说明和系统提示由“返回文件”扩展为“返回文件或生成的图片”,工具名和输入结构不变。
7.2 渠道层
- 只在确实有独立图片消息协议的渠道对象上增加
sendImage。 sendImage接受与现有sendFile相同的物化文件对象,并尽量保持相同的目标、reply、thread、signal 和超时参数形式。- 共享层不引用任何平台 SDK;渠道层不重新实现 Artifact 物化、释放、回执合并或降级策略。
7.3 不改动的数据契约
OutboundArtifact字段不变。dsh_im_return_file名称和参数不变。DeliveryReceipt保持 schema version 1。- 现有错误码继续使用;只有渠道缺少精确映射时,补齐到已有
artifact-*错误语义。 - Bot 配置文件不增加
nativeImage或格式列表。
8. 实施顺序
阶段 1:纯抽取,共享现有文件投递
- 从
text-harness-bridge.mjs抽取deliverOutboundArtifacts。 - 首先只调用
sendFile,确保行为、回执、失败通知和状态计数不变。 - 迁移重复的 Bridge 循环,每迁移一个渠道就运行其现有测试。
这一阶段不改变用户可见行为,便于把“代码抽取问题”和“原生图片协议问题”分开排查。
阶段 2:加入共享图片选择策略
- 增加
image/*判断和可选sendImage。 - 落实“明确失败可降级、结果不确定不重发”的规则。
- 更新工具说明和系统提示,使模型知道生成图片也通过现有 Artifact 工具交付。
阶段 3:以飞书作为参考渠道
- 将现有修复二维码的图片上传/发送能力整理为
FeishuChannel.sendImage。 - 接入普通 Artifact,并验证 PNG、JPEG、非图片文件和失败降级。
- 若复用修复二维码路径会扩大改动面,可先保留其调用点,仅复用底层私有函数;不强求一次消除所有重复。
阶段 4:完成 Issue #36 与其余渠道
- 微信:参考腾讯上游已工作的图片上传/消息结构,优先完成 Issue #36。
- 企业微信、QQ、Telegram、WhatsApp、钉钉:各自增加薄
sendImage适配。 - Slack、Discord:不写新发送路径,只验证当前附件在官方客户端中确实内联显示。
阶段 5:清理重复实现
- 在所有渠道契约测试通过后,删除已被共享函数取代的
#deliverArtifacts重复代码。 - 不顺便重构文本、卡片、语音等其他投递路径。
9. 测试方案
9.1 共享单元测试
至少覆盖以下决策表:
| 场景 | 预期 |
|---|---|
| 非图片产物 | 只调用一次 sendFile |
图片且存在 sendImage |
只调用一次 sendImage,presentation 为 image |
图片但不存在 sendImage |
调用一次 sendFile |
sendImage 明确拒绝 |
随后调用一次 sendFile |
sendImage 返回不确定错误 |
不调用 sendFile,Artifact outcome 为 unknown |
| 投递期间 Abort | 不降级、不发送失败通知,继续向上抛出 |
| 图片和文件混合多产物 | 保持原有顺序,分别生成并合并回执 |
| 部分成功、部分失败 | 成功项不重发,失败项有正确回执和安全通知 |
| 任意结果 | 每个 Artifact 最终只释放一次 |
9.2 渠道契约测试
对飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 分别验证:
- 图片上传请求的媒体类型正确。
- 最终消息请求使用图片类型而非文件类型。
- reply/thread/context token 等原有上下文没有丢失。
- 平台明确拒绝与结果不确定被映射到不同错误语义。
- 相同
deliveryKey继续参与现有幂等或稳定消息 ID 机制。
对 Slack、Discord 验证:
image/png和image/jpeg继续走一次附件上传。- 请求保留正确 MIME/Content-Type 和文件名。
- 不会因为不存在
sendImage而额外尝试第二次上传。
9.3 真机验收
每个渠道至少保留以下证据:
- PNG/JPEG 在官方客户端中的消息截图或录屏。
- 非图片文件仍按原行为发送。
- 一个平台不支持的图片格式或模拟明确拒绝能降级为文件。
- 模拟最终发送超时不会紧接着出现重复文件。
自动化测试能验证协议形状,不能完全代替客户端是否“原生显示”的验收。
9.4 九渠道真机验收记录(2026-08-24)
统一使用工作区图片 assets/logo-plugin-phone.png(PNG,85,920 字节),由用户在每个渠道的真实会话中要求 Agent 调用 dsh_im_return_file。以下时间均为 Asia/Taipei:
| 渠道 | 实际发送路径 | 官方客户端可见结果 | 结果 |
|---|---|---|---|
| Telegram | sendPhoto |
03:07 出现一张原生图片消息 | 通过 |
| Slack | 单次 external file upload | 03:08 在消息列中显示一张内联图片预览 | 通过 |
| Discord | 单次 multipart attachment | 03:10 在 Bot 私信中显示一张内联图片附件 | 通过 |
| 飞书 | image.create + msg_type: image |
03:11 在“梁子文件传送”会话中显示一张原生图片 | 通过 |
| 微信 | CDN 图片上传 + image_item |
03:12 在 DeepSeek Harness 会话中显示一张图片气泡 | 通过 |
| 钉钉 | image media + sampleImageMsg |
03:14 在“梁子来了”会话中显示一张原生图片,侧栏标记 [Image] |
通过 |
| 企业微信 | uploadMedia(image) + image media message |
03:15 在“今天是梁子”会话中显示一张原生 [Photo] |
通过 |
SDK sendImage |
03:15 在 winBot 私聊中显示一张原生图片,侧栏标记 [图片] |
通过 | |
Baileys { image, mimetype } |
03:16 在账号自聊中显示一张原生图片并保留引用上下文 | 通过 |
九个客户端均只出现一份图片产物,没有额外文件 fallback、重复图片或失败提示。事后状态检查中九个目标 Bot 均为 connected / healthy;除飞书状态投影不含本轮计数外,其余八个渠道均记录 1 条接收、1 条回复,Host 日志未出现 Artifact 投递失败、结果不确定或失败通知。
10. 验收标准
- 九个渠道中的 PNG、JPEG 都能在原生客户端直接看到图片或图片附件预览。
- 飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 使用各自的图片消息能力。
- Slack、Discord 使用其原生图片附件预览,且只上传一次。
- 非
image/*产物仍只走现有sendFile。 - 原生图片被明确拒绝时自动降级为文件。
- 结果不确定或 Abort 时不会二次发送。
- Session/Turn 绑定、不可变快照、摘要校验、释放生命周期均保持不变。
- 回执仍为
DeliveryReceiptv1,成功、失败、拒绝、不确定语义可区分。 - 不新增工具、Bot 配置项、本地格式白名单或完整渠道能力框架。
- 相关单元测试、渠道测试、
npm test和npm run build通过,并完成九渠道真机验收。
11. 风险与控制
| 风险 | 控制方式 |
|---|---|
| 原生发送超时后降级导致重复图片 | 复用并严格执行 artifact-delivery-uncertain;不确定时禁止降级 |
| 各平台图片格式限制不同 | 不在核心写死矩阵;平台明确拒绝后走文件 |
| 抽取共享函数造成既有文件回归 | 先做“纯抽取”提交,行为无变化,再加入图片策略 |
| 渠道 Bridge 上下文差异过大 | 通过闭包注入目标和上下文,不设计统一的大参数对象 |
| 飞书二维码重构影响修复流程 | 优先复用底层私有函数;是否迁移旧调用点由测试结果决定 |
| Slack/Discord 被误判为未实现 | 以官方客户端中的原生附件预览作为验收标准,并保留真机证据 |