package channelbridge // 本文件承载 **IM 入站图片内联**(切片 IM-IMG 的上游接线;design/24 §4.2 末、 // design/08 §4.1 的同一条口径):把入站图片媒体的字节内联成 `data:` URI 内容部件, // 交给**既有视觉链路**进模型上下文 —— 主模型能收图就直进上下文,收不到就由 vision/OCR // 工具出一份文本描述(`internal/runtime/multimodal/vision`,loop 的 resolveImages)。 // // 四条口径(每条都是本切片刻意钉死的): // // 1. **图片路与文档路是两条路,绝不混淆**:文档走 `documents.ExtAllowed` → 共享文档存储 // → 抽取(inbound_documents.go);图片走**图片白名单**(`mediafetch.ImageMIMEOf` 的 // 字节裁决 + `mediafetch.MaxImageBytes`)→ data: URI 部件。图片绝不落文档存储、绝不进 // `document` 列,而是进消息 DTO 的 `image` 字段(design/16 §4.4)。 // // 2. **字节由适配器取回,桥接器绝不代取任何 URL**:取回发生在**各渠道适配器**入站时 // (平台资产来源白名单 + 逐跳重定向校验 + 边读边限 + 超时,全在 // `internal/channel/mediafetch` 的同一份 `Fetch` 上)。桥接器只消费 `Media.Data`; // 没有字节就是「渠道只给了远程引用,或代取失败」,照实说 —— **不去连接任何主机** // (F3「服务端代取任意 URL」的教训,也保证「非白名单来源 0 次请求」这条性质)。 // // 3. **字节嗅探是唯一裁决权**:`mediafetch.ImageMIMEOf` 判不出白名单光栅图(HTML 错误页、 // 改名载荷、SVG、判不定的二进制)就**不内联**,只给一行诚实提示 —— 绝不把判不定的 // 载荷顶着图片身份塞进上下文。 // // 4. **失败绝不静默,也绝不留空消息**:无字节 / 超限 / 字节不是图片都留一行**中文** // 诚实提示(进模型可见视图;绝不拼进持久化正文,design/24 §4.3)+ 一条带来源的日志。 // 日志只记 name/mime/size 与**来源主机**,绝不记完整 URL(平台资产地址可能带签名凭据)。 import ( "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision" ) // 图片侧的具名常量与提示文案(口径 4)。提示行形状与附件提示行一致(`[图片 <名> · …]`), // 但**按失败原因分类**:把「超限」说成「格式不支持」本身就是一种不诚实。 const ( // inboundImageUnknownName 是图片没有展示名时的占位。刻意与文件的「未命名文件」分开: // 把一张图叫成「未命名文件」会让模型对这条媒体的类型产生错误印象(微信 iLink 的 // image_item 既没有 file_name 也没有 mime,这条路径是常态而不是例外)。 inboundImageUnknownName = "未命名图片" // 诚实提示行(参考 `%s=展示名`、`%s=展示 MIME`、`%d=字节数`)。 inboundImageNoticeNoBytes = "[图片 %s · %s · %d 字节 · 无法读取:服务端没有拿到图片字节(渠道只给了远程引用,或代取失败)]" inboundImageNoticeNotImage = "[图片 %s · %s · %d 字节 · 无法读取:取回的内容不是服务端支持的图片格式(只支持 png/jpeg/gif/webp/bmp/tiff)]" inboundImageNoticeTooLarge = "[图片 %s · %s · %d 字节 · 无法读取:图片超过服务端体积上限(%d MiB)]" // inboundImageNoticeTooMany 是超出单条消息图片条数上限时的整条提示(不逐张刷屏)。 inboundImageNoticeTooMany = "[另有图片未处理:单条消息最多 %d 张]" ) // hasImageMedia 报告消息是否携带图片类媒体(不论有没有字节)——它让「纯图片消息」不再被 // 丢空判据判成「没有内容」而丢弃(与 hasFileMedia / hasVoiceMedia 同一条纪律)。 func hasImageMedia(msg adapter.UnifiedMessage) bool { for _, m := range msg.Media { if isImageMedia(msg, m) { return true } } return false } // isImageMedia 报告一条媒体是不是**图片类**。判定只有一份、在 `mediafetch.IsImageMedia`: // 适配器的字节内联与桥接器的消费必须对「哪些媒体是图片」给出同一个答案,否则会出现 // 「内联了但没人消费」(与 isFileMedia 同一去重要求)。 func isImageMedia(msg adapter.UnifiedMessage, m adapter.Media) bool { return mediafetch.IsImageMedia(&msg, m) } // imageMedia 返回消息里全部图片类媒体(保序)。 func imageMedia(msg adapter.UnifiedMessage) []adapter.Media { out := make([]adapter.Media, 0, len(msg.Media)) for _, m := range msg.Media { if isImageMedia(msg, m) { out = append(out, m) } } return out } // ingestInboundImages 把一条入站消息的图片媒体内联成图片部件(data: URI),返回要挂到该 // 消息上的部件与「读不到」的诚实提示。调用点与文档摄取并列(同在**闸门之后**),因此被 // 黑白名单或最小打扰拦下的消息绝不会有图片进上下文。 // // 它刻意**不收 ctx**:这里没有任何 I/O(字节已由适配器取回、嗅探与 base64 都是纯函数), // 没有可取消的东西 —— 假收一个 ctx 只会让人以为这里有取回。 func (b *Bridge) ingestInboundImages(msg adapter.UnifiedMessage) inboundContent { images := imageMedia(msg) if len(images) == 0 { return inboundContent{} } out := inboundContent{ parts: make([]model.Content, 0, len(images)), notices: make([]string, 0, len(images)), } for i, media := range images { if i >= documents.MaxPerMessage { // 条数上限与 Web 上传、适配器内联同源(documents.MaxPerMessage,绝不另造一个数)。 // 超出的图片**不静默**:整条提示一次,日志记下实际条数。 b.log.Warn("inbound images beyond per-message limit ignored", "platform", msg.Platform, "channel", msg.ChannelID, "images", len(images), "limit", documents.MaxPerMessage) out.notices = append(out.notices, fmt.Sprintf(inboundImageNoticeTooMany, documents.MaxPerMessage)) break } part, reason, notice := inlineImagePart(media) if reason != "" { // 只记来源主机(HostOf),绝不记 URL 原串:平台资产地址可能带签名凭据。 b.log.Warn("inbound image not inlined", "platform", msg.Platform, "channel", msg.ChannelID, "reason", reason, "name", media.Name, "mime", media.MIMEType, "size", media.Size, "host", mediafetch.HostOf(media.URL)) out.notices = append(out.notices, notice) continue } b.log.Debug("inbound image inlined as data uri", "platform", msg.Platform, "channel", msg.ChannelID, "name", media.Name, "size", len(media.Data)) out.parts = append(out.parts, part) } return out } // inlineImagePart 把一条图片媒体折成**图片部件**:三个返回值依次是部件(失败时为零值)、 // **失败原因键**(空串 = 成功;只进日志,绝不进模型上下文 —— 错误原文既不必要也不安全), // 以及给模型看的中文诚实提示行(成功时为空)。 // // 判据的顺序就是口径:**没有字节 → 超过图片上限 → 字节嗅探**。上限在嗅探之前判,因为一份 // 巨图连 base64 都不必构造。 func inlineImagePart(m adapter.Media) (model.Content, string, string) { if len(m.Data) == 0 { // 适配器没有内联字节:渠道只给了远程引用,或平台资产代取失败。桥接器**不代取** // (口径 2),因此这里只如实说,绝不自己去连接消息里的地址。 return model.Content{}, "no_bytes", imageNoticeLine(m, inboundImageNoticeNoBytes) } if int64(len(m.Data)) > mediafetch.MaxImageBytes { // 纵深:适配器内联层已按图片口径限过一次,这里再判一次 —— 两处同值 //(`mediafetch.MaxImageBytes` == vision 解码层硬顶),因此不可能出现「桥接收下了 // 适配器拒收的字节」,也不可能出现「放进去了但解码层接不住」。 return model.Content{}, "too_large", fmt.Sprintf(inboundImageNoticeTooLarge, imageDisplayName(m.Name), displayMIME(m.MIMEType), attachmentSize(m), mediafetch.MaxImageBytes>>20) } mime, ok := mediafetch.ImageMIMEOf(m.Data) if !ok { // 声明说它是图片、字节说不是(HTML 错误页 / 改名载荷 / SVG / 判不定):**不内联** // 并如实给一行。绝不静默当图片送进上下文。 return model.Content{}, "not_image", imageNoticeLine(m, inboundImageNoticeNotImage) } // data: URI 只经 vision 的**唯一**构造入口拼装(`vision.DataURI`),与循环里视觉解析器 // 读 data: URI 的那条短路路径逐字节一致;mime 用的是**嗅探所得**,不是渠道声明。 return model.Content{Type: model.ContentTypeImage, ImageURL: vision.DataURI(mime, m.Data)}, "", "" } // inboundImageColumn 取本回合要写进用户消息 `image` 列的图片(design/16 §4.4 的 DTO // `image` 字段)。该列是**单值**,因此只落**第一张**成功内联的图片 —— 与网关侧 // messageTextImageDocument 对多个图片部件的取舍逐字一致(其余图片仍进模型可见视图,只是 // 不重复落库)。没有图片部件时返回空串(读侧据此省略该键)。 func inboundImageColumn(images inboundContent) string { for _, c := range images.parts { if c.Type == model.ContentTypeImage && c.ImageURL != "" { return c.ImageURL } } return "" } // imageNoticeLine 渲染一行图片的诚实提示:与附件的 noticeLine 同形,只有展示名换成图片 // 占位(见 inboundImageUnknownName)。 func imageNoticeLine(m adapter.Media, template string) string { return fmt.Sprintf(template, imageDisplayName(m.Name), displayMIME(m.MIMEType), attachmentSize(m)) } // imageDisplayName 取图片的展示名:与文件同一条净化规则(cleanDisplayName),只是缺名时的 // 占位是「未命名图片」。 func imageDisplayName(name string) string { if cleaned := cleanDisplayName(name); cleaned != "" { return cleaned } return inboundImageUnknownName }