dsh-im-ops/docs/方案/出站图片原生呈现落地方案.md

21 KiB
Raw Permalink Blame History

出站图片原生呈现落地方案

状态:已实施;九渠道真机验收通过

日期:2026-08-24

关联:Issue #36、ADR-0001:Semantic Core with Native Channel Adapters、渠道原生能力建设方案

1. 结论

本方案不新建一套“媒体消息框架”,而是在现有出站产物链路上增加一种呈现选择:

  1. Agent 仍然只通过 dsh_im_return_file 返回产物,图片也是 OutboundArtifact,不增加 return_image 工具。
  2. 语义核心继续负责产物绑定、物化、校验、释放、回执和失败语义。
  3. 共享投递逻辑根据已物化产物的 mediaType 识别 image/*。
  4. 渠道已有 sendImage 时调用渠道原生图片 API;没有独立图片 API、但现有附件本身就能原生预览的渠道继续调用 sendFile。
  5. 原生图片发送被渠道明确拒绝时,自动降级为现有 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、DeliveryReceipt v1 和现有 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 已处理安全读取和生命周期。
  • DeliveryReceipt v1 已能表示 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、插件注册表或渠道配置开关。
  • 不升级 DeliveryReceipt schema。
  • 不做图片转码、压缩、缩略图、图集、富媒体排版或 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,
}

该函数只集中现有重复逻辑:

  1. 逐个检查 Abort。
  2. 物化 Artifact。
  3. 选择 sendImage 或 sendFile。
  4. 生成、合并现有 DeliveryReceipt v1。
  5. 失败时调用渠道注入的安全通知函数。
  6. 在 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;不复制上传循环
QQ 调用 SDK sendImage 已安装 QQ SDK 的图片方法;当前 Bridge 的超时、provider promise 跟踪和错误包装 在现有 Runtime/API 边界暴露 sendImage,保持和 sendFile 相同的消息上下文与错误语义
Telegram Bot API sendPhoto telegram-api.mjs 的请求、超时、reply/thread 参数和错误映射;Runtime 已有 sendFile 增加 sendPhoto/sendImage 薄封装
WhatsApp 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:纯抽取,共享现有文件投递

  1. 从 text-harness-bridge.mjs 抽取 deliverOutboundArtifacts。
  2. 首先只调用 sendFile,确保行为、回执、失败通知和状态计数不变。
  3. 迁移重复的 Bridge 循环,每迁移一个渠道就运行其现有测试。

这一阶段不改变用户可见行为,便于把“代码抽取问题”和“原生图片协议问题”分开排查。

阶段 2:加入共享图片选择策略

  1. 增加 image/* 判断和可选 sendImage。
  2. 落实“明确失败可降级、结果不确定不重发”的规则。
  3. 更新工具说明和系统提示,使模型知道生成图片也通过现有 Artifact 工具交付。

阶段 3:以飞书作为参考渠道

  1. 将现有修复二维码的图片上传/发送能力整理为 FeishuChannel.sendImage。
  2. 接入普通 Artifact,并验证 PNG、JPEG、非图片文件和失败降级。
  3. 若复用修复二维码路径会扩大改动面,可先保留其调用点,仅复用底层私有函数;不强求一次消除所有重复。

阶段 4:完成 Issue #36 与其余渠道

  1. 微信:参考腾讯上游已工作的图片上传/消息结构,优先完成 Issue #36。
  2. 企业微信、QQ、Telegram、WhatsApp、钉钉:各自增加薄 sendImage 适配。
  3. 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 真机验收

每个渠道至少保留以下证据:

  1. PNG/JPEG 在官方客户端中的消息截图或录屏。
  2. 非图片文件仍按原行为发送。
  3. 一个平台不支持的图片格式或模拟明确拒绝能降级为文件。
  4. 模拟最终发送超时不会紧接着出现重复文件。

自动化测试能验证协议形状,不能完全代替客户端是否“原生显示”的验收。

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] 通过
QQ SDK sendImage 03:15 在 winBot 私聊中显示一张原生图片,侧栏标记 [图片] 通过
WhatsApp 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 绑定、不可变快照、摘要校验、释放生命周期均保持不变。
  • 回执仍为 DeliveryReceipt v1,成功、失败、拒绝、不确定语义可区分。
  • 不新增工具、Bot 配置项、本地格式白名单或完整渠道能力框架。
  • 相关单元测试、渠道测试、npm test 和 npm run build 通过,并完成九渠道真机验收。

11. 风险与控制

风险 控制方式
原生发送超时后降级导致重复图片 复用并严格执行 artifact-delivery-uncertain;不确定时禁止降级
各平台图片格式限制不同 不在核心写死矩阵;平台明确拒绝后走文件
抽取共享函数造成既有文件回归 先做“纯抽取”提交,行为无变化,再加入图片策略
渠道 Bridge 上下文差异过大 通过闭包注入目标和上下文,不设计统一的大参数对象
飞书二维码重构影响修复流程 优先复用底层私有函数;是否迁移旧调用点由测试结果决定
Slack/Discord 被误判为未实现 以官方客户端中的原生附件预览作为验收标准,并保留真机证据

12. 参考资料