- 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 已写盯梢用例)
297 lines
14 KiB
Go
297 lines
14 KiB
Go
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{})
|
||
}
|