package vision import ( "context" "fmt" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是 design/28 §7 判据 2(M4 闭环)的**视觉侧**:把一张**已落盘的页面截图** // 变成「能进上下文的一段内容」。 // // 为什么它必须存在(V5 留下的缺口,design/28 §8 的 B3):截图工具此前只把落盘路径交给 // 模型,由模型自己再调一次 vision —— 那不是闭环,是「模型记得的话就补一刀」。闭环的 // 定义(§1 M4)是:截图 → **自动**进上下文 ⇒ 主模型「看见」这一页,而不是读一页 HTML。 // // 两条路都复用既有机制,不新造第二套: // // 1. **主模型能收图**(models.json 的 `multimodal` 声明,裁决 14.4.1 的口径):把截图 // 内联成 data: URI 的 image 部件(与用户附图走**同一条** `inlineImageCached`), // 它对主模型而言就是一张附件图(design/24 的附件语义); // 2. **主模型读不了图**:走**工具形态**适配器(vision / OCR 工具),把图折叠成一段 // 文字描述(design/28 §5 的表格行:截图的下游 = vision)。 // // 大图先过裁切(主人点名的要求,design/28 §5 的 `io/crop` 那一行):截图的落盘上限 // (`browsercfg.DefaultScreenshotMaxBytes` = 8 MiB,可配到 32 MiB)**大于**内联解码层 // 的上限(`MaxInlineImageBytes` = 5 MiB),所以存在「拍得下、但内联不进去」的真区间。 // 这一段的处理写在 `imageForMain`:先按预算裁一块送进去,并**如实标注**裁掉了什么、 // 完整图在哪 —— 模型因此仍能用 `image-crop` 自取其余区域(crop 的两条注释正是这个用法)。 // // 三条红线(design/28 §4): // - 截图路径**只**从调用方得到(本文件不做目录扫描、不猜文件名); // - 超限、缺工具、裁切失败一律**如实**降级/报错,绝不交出半张坏图,也绝不假装成功; // - 任何日志/错误里都**不含图片内容**(错误只带上限与本次长度)。 const ( // ShotRouteMain 报告这条附件走的是「内联给主模型」。 ShotRouteMain = "main" // ShotRouteVisionTool 报告走的是 vision 工具形态(图不进主模型上下文)。 ShotRouteVisionTool = "vision_tool" // ShotRouteOCRTool 报告走的是 OCR 工具形态。 ShotRouteOCRTool = "ocr_tool" ) // 裁切规则里的具名常量(都是「实际后果」倒推出来的,不是随手取的数)。 const ( // shotCropMinRatio 是裁切后至少保留原图高度的比例。再小就只剩一条边,送进去 // 的图读不出页面结构(等于没说),此时宁可走工具形态。 shotCropMinRatio = 0.25 // shotCropMinHeight 是裁切高度的绝对下限(像素)。极扁的页面按比例算出来的 // 一条窄带没有信息量,因此给一个下限;仍算不出就放弃裁切。 shotCropMinHeight = 120 // shotCropMaxAttempts 是最多裁几次:每次按预算比缩小,第一次不成再试一次 // 就够(估算偏差来自 PNG 的压缩率变化,不会差一个数量级)。多次裁剪会把 // 一次截图变成多次工具调用,反而不如走工具形态。 shotCropMaxAttempts = 2 ) // ShotRef 是一张**已落盘**的页面截图(`browser` 能力的产物,design/28 §3)。 // // 为什么带着统计字段而不是只有一个路径:裁切区域与「能不能内联」都要按真实字节与 // 真实像素算(`internal/capability/browser/shot.go` 从字节里解出来,不靠视口配置冒充)。 // 零值 `Bytes`/`Width`/`Height` 表示调用方不知道 —— 那几档**不猜**:只按路径内联或 // 直接走工具形态,绝不编一个区域去裁。 type ShotRef struct { // Path 是截图的本地绝对路径(0600 落盘)。 Path string // Bytes 是落盘字节数。 Bytes int // Width / Height 是从字节里解出的真实像素尺寸。 Width int Height int // Format 是实际编码("png" / "jpeg")。 Format string } // ShotCropper 是「按区域裁一张图,返回可内联的 data: URI」的接缝。 // // 生产实现是既有的 `image-crop` 工具(经工具分发器调用,`plugins/tools/io/crop.go`); // nil 表示这台机器上没有裁切能力 —— 那时大图**不裁**,如实改走工具形态(见 Attach)。 // 用接口而不是直接依赖 io 包:本包不持有工具注册表,也不需要知道裁剪是怎么实现的。 type ShotCropper interface { // CropRegion 裁出 (x, y, w, h) 并返回 `data:;base64,…`。 CropRegion(ctx context.Context, path string, x, y, w, h int) (string, error) } // ShotAttachment 是一张截图进上下文的结论。它是**可观测的事实**:路由走了哪条、 // 图被怎么处理过,都写在这里,由调用方(loop 的装配钩子)如实带进上下文与日志。 type ShotAttachment struct { // Content 是挂到消息上的内容部件:image(内联给主模型)或 text(工具形态的描述)。 Content model.Content // Route 是本次实际走的路由(本文件的 ShotRoute* 常量)。 Route string // Note 是给模型看的一句事实说明(图被裁过、走的是描述而不是原图…), // 与内容部件放在同一条消息里 —— 模型据此才知道自己看到的是不是全图。 Note string // Cropped 报告这张图是否被裁过(Route 仍是 main)。 Cropped bool // CropRegion 是被裁下来的区域("x,y,w,h");未裁时为空。 CropRegion string } // ShotAttacher 把页面截图变成附件内容。它复用 `MessageResolver` 的路由与内联实现 // (同一个 Router、同一份 data: URI 缓存),因此截图与用户附图**不会**走出两种行为。 type ShotAttacher struct { resolver *MessageResolver cropper ShotCropper // budget 是内联预算(解码后字节)。0 ⇒ `MaxInlineImageBytes`。 budget int } // ShotAttacherOption 配置 ShotAttacher。 type ShotAttacherOption func(*ShotAttacher) // WithShotCropper 注入裁切能力(生产是 `image-crop` 工具;nil = 没有)。 func WithShotCropper(c ShotCropper) ShotAttacherOption { return func(s *ShotAttacher) { s.cropper = c } } // WithShotInlineBudget 覆盖内联预算(解码后字节)。非正值原样保留为 0 ⇒ 用默认上限。 // 存在的意义是让「大图」这一档**可测**:真造一张 >5 MiB 的 PNG 既慢又占磁盘。 func WithShotInlineBudget(n int) ShotAttacherOption { return func(s *ShotAttacher) { s.budget = n } } // NewShotAttacher 构造附件器。resolver 为 nil 时构造成功但 Attach 一律报 // ErrNotConfigured(**不静默**:那会让「截图静默不进上下文」变成一个查不出的谜)。 func NewShotAttacher(resolver *MessageResolver, opts ...ShotAttacherOption) *ShotAttacher { s := &ShotAttacher{resolver: resolver} for _, o := range opts { if o != nil { o(s) } } return s } // inlineBudget 返回本次生效的内联预算。 func (s *ShotAttacher) inlineBudget() int { if s == nil || s.budget <= 0 { return MaxInlineImageBytes } return s.budget } // Attach 把一张截图变成附件内容。返回的 Note 一定非空(模型据此知道自己看到的是什么)。 // // 路由顺序(每一步失败都**如实**报告,绝不回头假装成功): // // ① 主模型能收图 + 图在内联预算内 ⇒ 直接内联(Route=main) // ② 主模型能收图 + 图超预算 ⇒ 先裁一块内联(Route=main,Cropped=true) // ③ 裁不了 / 裁完仍超预算 / 主模型读不了图 ⇒ 工具形态(Route=vision_tool / ocr_tool) // ④ 以上都不成立(没有工具、没有路由)⇒ 返回错误,由调用方如实降级 func (s *ShotAttacher) Attach(ctx context.Context, shot ShotRef) (ShotAttachment, error) { if s == nil || s.resolver == nil { return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w", ErrNotConfigured) } path := strings.TrimSpace(shot.Path) if path == "" { return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 截图路径为空", ErrUnsupportedSource) } decision := s.resolver.decision() if decision.Kind == KindMain { if att, err := s.imageForMain(ctx, shot, path); err == nil { return att, nil } // 内联这条路走不通(文件读不出来 / 超限且裁不了)⇒ 退回工具形态。 // 这不是「静默降级」:Note 里写明为什么给的是描述而不是原图。 return s.toolForm(ctx, path, decision) } return s.toolForm(ctx, path, decision) } // imageForMain 处理「主模型能收图」这一路:先把整图内联,超预算再裁一块。 func (s *ShotAttacher) imageForMain(ctx context.Context, shot ShotRef, path string) (ShotAttachment, error) { budget := s.inlineBudget() if shot.Bytes <= 0 || shot.Bytes <= budget { src, err := inlineImageCached(ctx, s.resolver.cache, path) if err != nil { return ShotAttachment{}, err } return ShotAttachment{ Content: model.Content{Type: model.ContentTypeImage, ImageURL: src}, Route: ShotRouteMain, Note: fmt.Sprintf("[截图] 这一页的视口截图已作为图片附件送出(%s)。", path), }, nil } return s.cropped(ctx, shot, path, budget) } // cropped 把超预算的大图裁一块出来内联。 // // 区域规则(唯一一条,避免「猜哪里重要」):**顶部锚定、整宽**,高度按「预算 / 实际字节」 // 等比缩小(下限 shotCropMinRatio 与 shotCropMinHeight)。理由: // - 截的是**视口**(`captureBeyondViewport=false`,从 y=0 开始),页面语义上最靠前的 // 内容就在顶部;选「中间/第 N 象限」并不比顶部更不武断,而顶部至少与「视口起点」同源; // - 缩放不可用(crop 只裁不缩),所以「够不够小」只能靠**少裁一点**来控制; // - 裁切一定会被**如实标注**:Note 里写明保留了哪一块、完整图在哪、其余区域怎么取。 // // 尺寸未知(`DimensionsKnown=false` ⇒ Width/Height 为 0)时**不裁**:那种情况下任何区域 // 都是编的。裁完仍超预算同样不硬塞 —— 如实退回工具形态(图仍然被读到了,只是以文字形式)。 func (s *ShotAttacher) cropped(ctx context.Context, shot ShotRef, path string, budget int) (ShotAttachment, error) { if s.cropper == nil { return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 没有裁切能力可用", ErrNotConfigured) } if shot.Width <= 0 || shot.Height <= 0 { return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 截图尺寸未知,不猜裁切区域", ErrUnsupportedSource) } ratio := float64(budget) / float64(shot.Bytes) for attempt := 0; attempt < shotCropMaxAttempts; attempt++ { keep := cropKeepHeight(shot.Height, ratio) if keep <= 0 { return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 裁切区域小于下限", ErrUnsupportedSource) } uri, err := s.cropper.CropRegion(ctx, path, 0, 0, shot.Width, keep) if err != nil { return ShotAttachment{}, err } if len(uri) > MaxInlineImageChars { // 估算偏乐观(PNG 压缩率随内容变化)⇒ 按缺口再缩一次,仍超预算就走工具形态。 ratio *= 0.5 continue } return ShotAttachment{ Content: model.Content{Type: model.ContentTypeImage, ImageURL: uri}, Route: ShotRouteMain, Note: fmt.Sprintf( "[截图] 这一页的截图(%d×%d,%d 字节)超过单图内联上限,已先裁出**顶部 %d×%d** 送出;"+ "完整图在 %s,其余区域可用 image-crop 按区域自取。", shot.Width, shot.Height, shot.Bytes, shot.Width, keep, path), Cropped: true, CropRegion: fmt.Sprintf("0,0,%d,%d", shot.Width, keep), }, nil } return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 裁切后仍超过单图内联上限", ErrImageTooLarge) } // cropKeepHeight 按比例算出保留的像素高度,并夹上两条下限(见常量注释)。 // 返回 0 表示「算不出有意义的区域」(调用方据此放弃裁切)。 func cropKeepHeight(height int, ratio float64) int { if height <= 0 || ratio <= 0 { return 0 } keep := int(float64(height) * ratio) if min := int(float64(height) * shotCropMinRatio); keep < min { keep = min } if keep < shotCropMinHeight { keep = shotCropMinHeight } if keep > height { keep = height } if keep <= 0 { return 0 } return keep } // toolForm 走工具形态:vision / OCR 工具产出文字,图本身不进主模型上下文。 func (s *ShotAttacher) toolForm(ctx context.Context, path string, decision Decision) (ShotAttachment, error) { if decision.Kind == KindMain { // 主模型的图路没走通 ⇒ 用工具形态兜底(有工具的话)。 decision = s.resolver.toolDecision() } content, err := s.resolver.resolveToolRoute(ctx, path, shotPrompt, decision) if err != nil { return ShotAttachment{}, err } route := ShotRouteVisionTool if decision.Kind == KindOCRTool { route = ShotRouteOCRTool } return ShotAttachment{ Content: content, Route: route, Note: fmt.Sprintf( "[截图] 主模型收不了图(或图超过内联上限且无法裁切),已用 %s 工具读出这一页并附上文字结果;"+ "截图本身在 %s,可按区域用 image-crop 再读。", decision.Tool, path), }, nil } // shotPrompt 是走 vision 工具读**页面截图**时的指令:与用户附图的 describePrompt 不同, // 这里点明「页面截图」,因为页面上的坐标/表格/按钮文字是这类图最常被追问的东西。 const shotPrompt = "This is a screenshot of a web page. Describe the page content, " + "including any text, layout, charts or interactive elements that are visible." // toolDecision 取「工具形态」的路由:主模型那条路不可用时,问一次 Router 要看工具。 // 主模型能收图时 Router 恒返回 KindMain,因此这里把能力显式置为「收不了图」再问一次 —— // 这是 Router 的既有语义(第一层条件是 SupportsVision),不是新策略。 func (r *MessageResolver) toolDecision() Decision { if r == nil || r.router == nil { return Decision{Kind: KindUnsupported, Reason: "no vision router configured"} } return r.router.Route(ModelInfo{SupportsVision: false}, RouteRequest{}) }