Files
Cyrene_v2/internal/services/channelbridge/inbound_images.go
T

184 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}