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 已写盯梢用例)
This commit is contained in:
AskaEth committed 2026-10-06 21:48:50 +08:00
1 parent 4213b268fe
commit e0a6bca501
18 files changed
+3698 -72

No files matched your search

+31 -7
View File
@@ -68,16 +68,29 @@ func (r *MessageResolver) Resolve(ctx context.Context, msg model.Message) (model
return model.Message{Role: msg.Role, Content: out}, nil
}
// resolveImage 选出一条路由:要么保留图像(KindMain),
// 要么经 vision/OCR 工具把它折叠为文字描述。
func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) {
// describePrompt 是走 vision 工具路由时给视觉模型的默认指令。它是**命名常量**
// 而不是散在两处的字面量:`resolveImage` 与 `ShotAttacher` 必须用同一句,
// 否则同一种图会因为「谁发的」而得到两种提问方式(结果不可比)。
const describePrompt = "Describe this image in detail."
// decision 是路由判定的唯一读取点:把「主模型能力 → Router」这一步收在一处,
// 使 resolveImage(用户附件)与 ShotAttacher(截图附件)不可能各自算一次而分叉。
func (r *MessageResolver) decision() Decision {
if r == nil || r.router == nil {
return Decision{Kind: KindUnsupported, Reason: "no vision router configured"}
}
target := ModelInfo{}
if r.info != nil {
target = r.info()
}
decision := r.router.Route(target, RouteRequest{})
switch decision.Kind {
case KindMain:
return r.router.Route(target, RouteRequest{})
}
// resolveImage 选出一条路由:要么保留图像(KindMain),
// 要么经 vision/OCR 工具把它折叠为文字描述。
func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) {
decision := r.decision()
if decision.Kind == KindMain {
// 支持视觉的主模型直接看到这张图;但 URL 与本地路径必须先由服务端
// 取回并内联为 data: URI——云 provider 取不到自部署的局域网地址
// (如 http://192.168.x.x:10942/api/v1/files/…),Anthropic 协议更是
@@ -90,10 +103,21 @@ func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (mo
return model.Content{}, err
}
return model.Content{Type: model.ContentTypeImage, ImageURL: src}, nil
}
return r.resolveToolRoute(ctx, imageURL, describePrompt, decision)
}
// resolveToolRoute 执行工具形态的那两条路由(vision / OCR):图像不进主模型
// 上下文,只把工具产出的文字折叠成一个文本部件。没有工具可用时如实报
// ErrUnsupportedSource(**绝不**悄悄把图像留给一个读不了图的主模型)。
func (r *MessageResolver) resolveToolRoute(
ctx context.Context, imageURL, prompt string, decision Decision,
) (model.Content, error) {
switch decision.Kind {
case KindVisionTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
"image": imageURL,
"prompt": "Describe this image in detail.",
"prompt": prompt,
}, "description")
case KindOCRTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
+296
View File
@@ -0,0 +1,296 @@
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{})
}
@@ -0,0 +1,307 @@
package vision
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住 design/28 §7 判据 2 的**视觉侧**(切片 Y4 / B3):一张已落盘的页面截图
// 到底以什么形态进上下文 —— 主模型能收图时是**真的图**(image 部件 + data: URI),
// 收不了图时是**工具形态的文字**,超内联上限时**先裁再送**,每一条都带如实标注。
//
// 用具名假件而不是真浏览器:本文件的被测对象是「图怎么进上下文」,浏览器那一层由
// `internal/capability/browser` 与 `cmd/cyrene/wire_browser*` 的用例负责,两处不混。
// shotPNG 造一张真实的小 PNG(真字节,因为视觉层对图像**字节嗅探**,假字节会被拒)。
func shotPNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x40, A: 0xff})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("编码 PNG: %v", err)
}
return buf.Bytes()
}
// writeShot 把 PNG 落进临时目录并返回与落盘结论同形的 ShotRef。
func writeShot(t *testing.T, data []byte, w, h int) ShotRef {
t.Helper()
path := filepath.Join(t.TempDir(), "shot-test.png")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("写截图: %v", err)
}
return ShotRef{Path: path, Bytes: len(data), Width: w, Height: h, Format: "png"}
}
// fakeCropper 记录裁切区域并返回一段可控长度的 data: URI。
type fakeCropper struct {
calls []string
uri string
failAll bool
}
func (c *fakeCropper) CropRegion(_ context.Context, path string, x, y, w, h int) (string, error) {
c.calls = append(c.calls, fmt.Sprintf("%s|%d,%d,%d,%d", filepath.Base(path), x, y, w, h))
if c.failAll {
return "", errors.New("crop: 假失败")
}
return c.uri, nil
}
// visionInvoker 造一个「记录调用参数并返回固定描述」的工具调用器。
func visionInvoker(t *testing.T, calls *[]string, description string) ToolInvoker {
t.Helper()
return func(_ context.Context, name string, args map[string]any) (any, error) {
*calls = append(*calls, fmt.Sprintf("%s|%v", name, args["image"]))
if name == "ocr" {
return map[string]any{"text": description}, nil
}
return map[string]any{"description": description}, nil
}
}
// newShotFixture 造一个视觉路由夹具。sight 决定主模型能不能收图。
func newShotFixture(t *testing.T, sight bool, cropper ShotCropper, budget int) (*ShotAttacher, *[]string) {
t.Helper()
calls := &[]string{}
rt := NewRouter(WithVisionTool("vision"), WithOCRTool("ocr"))
res := NewMessageResolver(rt, func() ModelInfo {
return ModelInfo{Name: "main", SupportsVision: sight}
}, visionInvoker(t, calls, "页面上写着「渲染后才出现的文字」"))
opts := []ShotAttacherOption{WithShotCropper(cropper)}
if budget > 0 {
opts = append(opts, WithShotInlineBudget(budget))
}
return NewShotAttacher(res, opts...), calls
}
// TestShotAttach_VisionModelGetsRealImage 钉住判据 2 的正面:主模型能收图时,
// 附件是**真的 image 部件**(data: URI),不是一句「图在哪」的路径。
func TestShotAttach_VisionModelGetsRealImage(t *testing.T) {
t.Parallel()
data := shotPNG(t, 8, 8)
shot := writeShot(t, data, 8, 8)
att, calls := newShotFixture(t, true, nil, 0)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteMain {
t.Errorf("Route = %q, want %q", got.Route, ShotRouteMain)
}
if got.Content.Type != model.ContentTypeImage {
t.Fatalf("Content.Type = %q, want image(主模型能收图时必须给真图)", got.Content.Type)
}
if !strings.HasPrefix(got.Content.ImageURL, "data:image/png;base64,") {
t.Errorf("内联结果不是 PNG data: URI:%.60s", got.Content.ImageURL)
}
if got.Note == "" {
t.Error("Note 为空:模型无法据此知道自己看到的是哪张图")
}
if len(*calls) != 0 {
t.Errorf("主模型能收图时不该调用 vision/OCR 工具,却调了 %v", *calls)
}
if got.Cropped {
t.Error("小图不该被裁")
}
}
// TestShotAttach_TextModelGetsToolForm 钉住另一条路:主模型收不了图时走工具形态,
// 图**不进**上下文,进去的是 vision 工具产出的文字。
func TestShotAttach_TextModelGetsToolForm(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 8, 8), 8, 8)
att, calls := newShotFixture(t, false, nil, 0)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteVisionTool {
t.Errorf("Route = %q, want %q", got.Route, ShotRouteVisionTool)
}
if got.Content.Type != model.ContentTypeText {
t.Fatalf("Content.Type = %q, want text(图不该进读不了图的主模型)", got.Content.Type)
}
if !strings.Contains(got.Content.Text, "渲染后才出现的文字") {
t.Errorf("工具产出的描述没进内容:%q", got.Content.Text)
}
if len(*calls) != 1 || !strings.HasPrefix((*calls)[0], "vision|") {
t.Fatalf("工具调用 = %v,want 一次 vision 且带截图路径", *calls)
}
if !strings.Contains((*calls)[0], shot.Path) {
t.Errorf("工具拿到的不是截图路径:%v", *calls)
}
}
// TestShotAttach_OCRToolRoute 钉住 OCR 档(路由器只有 OCR 工具时的路由与字段名不同:
// 读 "text" 而不是 "description")。
func TestShotAttach_OCRToolRoute(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 8, 8), 8, 8)
calls := &[]string{}
res := NewMessageResolver(NewRouter(WithOCRTool("ocr")), func() ModelInfo {
return ModelInfo{SupportsVision: false}
}, visionInvoker(t, calls, "OCR 出来的字"))
att := NewShotAttacher(res)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteOCRTool {
t.Errorf("Route = %q, want %q", got.Route, ShotRouteOCRTool)
}
if !strings.Contains(got.Content.Text, "OCR 出来的字") {
t.Errorf("OCR 结果没进内容:%q", got.Content.Text)
}
}
// TestShotAttach_OversizedShotsAreCroppedFirst 钉住主人点名的「大图先过 crop」:
// 超内联上限的截图先按区域裁一块送进去,且**如实标注**保留了哪一块、完整图在哪。
func TestShotAttach_OversizedShotsAreCroppedFirst(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 40, 40) // 真实字节数远大于 1
cropper := &fakeCropper{uri: "data:image/png;base64,AAAA"}
att, _ := newShotFixture(t, true, cropper, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteMain || got.Content.Type != model.ContentTypeImage {
t.Fatalf("裁切后应仍是给主模型的图:route=%q type=%q", got.Route, got.Content.Type)
}
if !got.Cropped || got.CropRegion == "" {
t.Errorf("裁切事实没被如实标注:Cropped=%v Region=%q", got.Cropped, got.CropRegion)
}
if len(cropper.calls) != 1 {
t.Fatalf("裁切调用 = %v,want 恰好一次", cropper.calls)
}
// 区域规则:顶部锚定、整宽(x=0,y=0),高度夹在 (0, 原图高] 内。
want := fmt.Sprintf("shot-test.png|0,0,40,%d", cropKeepHeight(40, 1.0/float64(shot.Bytes)))
if cropper.calls[0] != want {
t.Errorf("裁切区域 = %q, want %q", cropper.calls[0], want)
}
for _, kw := range []string{shot.Path, "image-crop", "40×40"} {
if !strings.Contains(got.Note, kw) {
t.Errorf("标注里缺 %q:%q", kw, got.Note)
}
}
}
// TestShotAttach_OversizedWithoutCropperFallsBackToToolForm 钉住降级方向:
// 没有裁切能力时**不硬塞**(也不假装成功),如实改走工具形态,并在标注里说明原因。
func TestShotAttach_OversizedWithoutCropperFallsBackToToolForm(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 40, 40)
att, calls := newShotFixture(t, true, nil, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteVisionTool || got.Content.Type != model.ContentTypeText {
t.Fatalf("没有裁切能力时应走工具形态:route=%q type=%q", got.Route, got.Content.Type)
}
if len(*calls) != 1 {
t.Fatalf("工具调用 = %v,want 一次", *calls)
}
if !strings.Contains(got.Note, "无法裁切") && !strings.Contains(got.Note, "内联上限") {
t.Errorf("降级原因没写在标注里:%q", got.Note)
}
}
// TestShotAttach_CropStillTooLargeFallsBack 钉住「裁完还超限」这一档:不硬塞半张坏图,
// 降级到工具形态(图仍然被读到了,只是以文字形式)。
func TestShotAttach_CropStillTooLargeFallsBack(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 40, 40)
cropper := &fakeCropper{uri: "data:image/png;base64," + strings.Repeat("A", MaxInlineImageChars)}
att, _ := newShotFixture(t, true, cropper, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteVisionTool {
t.Errorf("裁完仍超限时应降级到工具形态:%q", got.Route)
}
if len(cropper.calls) > shotCropMaxAttempts {
t.Errorf("裁切次数 %d 超过上限 %d", len(cropper.calls), shotCropMaxAttempts)
}
}
// TestShotAttach_CropSkippedWhenDimensionsUnknown 钉住「不猜区域」:字节超限但尺寸未知时
// **一次都不裁**(任何区域都会是编出来的),直接走工具形态。
func TestShotAttach_CropSkippedWhenDimensionsUnknown(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 0, 0)
cropper := &fakeCropper{uri: "data:image/png;base64,AAAA"}
att, _ := newShotFixture(t, true, cropper, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if len(cropper.calls) != 0 {
t.Errorf("尺寸未知时不该裁切,却调了 %v", cropper.calls)
}
if got.Route != ShotRouteVisionTool {
t.Errorf("Route = %q, want 工具形态", got.Route)
}
}
// TestShotAttach_NoToolNoRouteIsHonest 钉住最坏一档:主模型读不了图、也没绑任何工具时
// **如实报错**(绝不返回一个「成功但没有图」的附件 —— 那会让模型以为页面里什么都没有)。
func TestShotAttach_NoToolNoRouteIsHonest(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 8, 8), 8, 8)
res := NewMessageResolver(NewRouter(), func() ModelInfo {
return ModelInfo{SupportsVision: false}
}, nil)
att := NewShotAttacher(res)
_, err := att.Attach(context.Background(), shot)
if !errors.Is(err, ErrUnsupportedSource) {
t.Fatalf("err = %v, want %v", err, ErrUnsupportedSource)
}
}
// TestShotAttach_GuardsAndMissingFile 钉住两条入口守卫与一条真实失败:
// nil resolver ⇒ ErrNotConfigured;空路径 ⇒ 报错;路径不存在 ⇒ 内联失败后**不假装**成功。
func TestShotAttach_GuardsAndMissingFile(t *testing.T) {
t.Parallel()
if _, err := NewShotAttacher(nil).Attach(context.Background(), ShotRef{Path: "x.png"}); !errors.Is(err, ErrNotConfigured) {
t.Errorf("nil resolver 的 err = %v, want %v", err, ErrNotConfigured)
}
att, _ := newShotFixture(t, true, nil, 0)
if _, err := att.Attach(context.Background(), ShotRef{Path: " "}); err == nil {
t.Error("空路径应当报错")
}
missing := ShotRef{Path: filepath.Join(t.TempDir(), "not-there.png"), Bytes: 10, Width: 8, Height: 8}
got, err := att.Attach(context.Background(), missing)
if err == nil && got.Content.Type == model.ContentTypeImage {
t.Error("文件不存在时不该交出一个 image 部件")
}
if err == nil && got.Route == ShotRouteMain {
t.Error("文件不存在时不该报 Route=main")
}
}