Files
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

308 lines
12 KiB
Go
Raw Permalink 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 (
"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")
}
}