184 lines
10 KiB
Go
184 lines
10 KiB
Go
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
|
||
}
|