Files
Cyrene_v2/internal/runtime/multimodal/vision/shot.go
T
AskaEth e0a6bca501 feat(browser): B3 截图→上下文闭环 + B4 面板声明页(Y4)
- B3:截图落盘后**自动**并入本轮视图(能收图 ⇒ 真 image 部件挂 user 消息;收不了 ⇒ vision/OCR 文字;
  大图先经 image-crop 裁切;取走即消费、每会话 ≤4 张、TTL 10min、跨会话隔离)
- B4:browser 面板**声明**(配置表单 + 运行状态/指纹自检/最近截图三张只读表 + 拍一张/重新自检)
  + 六个动作走 PLG-2 的通用动作端点(**不开新路由**)+ 保存事务(不可用则回滚 409)+ 热重载
- 读面**不启动浏览器**(计数型假 dialer 证明 0 次拨号);行数据不含路径/URL/内容
- 3 个变异(短路主路/行与列 key 脱钩/保存不回滚)
⚠️ **两行接线未落**(不在我文件面):wire_runtime.go 的 TransformContext + wire_frontend.go 的
   wireBrowserPanel —— 落地前 B3/B4 只是「组件就绪」,跑着的后端里不存在(Y4 已写盯梢用例)
2026-10-06 21:48:50 +08:00

297 lines
14 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 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:<mime>;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{})
}