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

+20
View File
@@ -94,6 +94,14 @@ type Manager struct {
closed bool
source string
// sessionOpenedAt 是当前会话的建立时间(面板的只读事实面用,见 state.go)。
sessionOpenedAt time.Time
// shots 是最近截图统计(**非内容**,最新在前,最多 maxShotRecords 条)。
shots []ShotRecord
// fp / fpAt 是最后一次指纹自检的读数与时间(面板显示「上次自检」用)。
fp *Fingerprint
fpAt time.Time
stopReaper chan struct{}
reaperDone chan struct{}
closeOnce sync.Once
@@ -171,6 +179,9 @@ type PageResult struct {
// Screenshot 抓一张视口截图并落盘(0600)。超过配置上限时先降级为 JPEG 重抓,
// 仍超限则**如实报错**,绝不交出半张坏图。
//
// 截图统计(字节/尺寸/格式)同时记进内存(`State().Shots`),供面板的只读事实面显示
// 「最近拍到了什么」—— 面板不读文件、不进上下文,只看统计(state.go 口径 2)。
func (m *Manager) Screenshot(ctx context.Context) (Shot, error) {
var shot Shot
err := m.withSession(ctx, func(s *session) error {
@@ -183,6 +194,7 @@ func (m *Manager) Screenshot(ctx context.Context) (Shot, error) {
return err
}
shot = saved
m.recordShotLocked(saved, time.Now())
return nil
})
if err != nil {
@@ -214,6 +226,9 @@ func (m *Manager) capture(ctx context.Context, s *session) ([]byte, string, erro
// Fingerprint 在**隔离世界**里读一次页面指纹(reference/10 §4.1 的 L2~L5 自检项),
// 用来证明「这次跑的是有头、webdriver 关、屏幕/时区/语言与设定一致」。
//
// 读数会被记进内存(`State().Fingerprint`),面板因此能显示「上次自检」而不必每次都
// 起浏览器(state.go 口径 1:面板取数无副作用,自检是一条显式动作)。
func (m *Manager) Fingerprint(ctx context.Context) (Fingerprint, error) {
var fp Fingerprint
err := m.withSession(ctx, func(s *session) error {
@@ -227,6 +242,7 @@ func (m *Manager) Fingerprint(ctx context.Context) (Fingerprint, error) {
if err != nil {
return Fingerprint{}, err
}
m.recordFingerprint(fp)
return fp, nil
}
@@ -273,6 +289,7 @@ func (m *Manager) ensureLocked(ctx context.Context) (*session, error) {
}
m.sess = s
m.lastUsed = time.Now()
m.sessionOpenedAt = m.lastUsed
m.log.Info("browser session opened", "source", m.source, "headless", m.cfg.headless)
return s, nil
}
@@ -292,6 +309,7 @@ func (m *Manager) recycleLocked(reason Reason) {
}
m.sess.Close()
m.sess = nil
m.sessionOpenedAt = time.Time{}
m.log.Info("browser session recycled", "reason", reason, "detail", reason.Message())
}
@@ -304,6 +322,7 @@ func (m *Manager) dropLocked(cause error) {
m.log.Warn("browser session dropped after connection error", "error", cause)
m.sess.Close()
m.sess = nil
m.sessionOpenedAt = time.Time{}
}
// reapLoop 是空闲清扫协程:滴答间隔跟随空闲上限的一半(夹在具名上下界之间)。
@@ -351,6 +370,7 @@ func (m *Manager) Close() error {
if m.sess != nil {
m.sess.Close()
m.sess = nil
m.sessionOpenedAt = time.Time{}
}
m.mu.Unlock()
})
+149
View File
@@ -0,0 +1,149 @@
package browser
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// 本文件是 design/28 §8 的 **B4(M3 面板入口)在驱动层的事实面**:面板要显示与
// 操作的那一组只读事实、以及「最近截图」这类**非内容**统计。
//
// 三条口径:
//
// 1. **面板取数不启动浏览器**:`State` 只读内存里已有的事实(配置、可用性、会话是否活着、
// 上次指纹自检、最近截图统计)。真正的自检(要起浏览器)是**一条显式动作**由主人点,
// 不是「打开面板就顺手起一个 Chrome」—— 面板的只读区块连 viewer 都能看(design/09
// §5.4.1),让「看一眼」产生进程外副作用是明确的越界;
// 2. **只存非内容事实**:这里存的是字节数/尺寸/格式/时间,**绝不存字节本身、绝不存页面
// 内容或 URL**。截图的正文只以文件形式存在(0600),面板只报「拍到了几张、多大」;
// 3. **有界**:最近截图统计最多留 `maxShotRecords` 条(环),指纹只留最后一次。
// maxShotRecords 是「最近截图」保留的条数。面板是一张只读表(排障用),20 条足够看出
// 「刚刚那一下到底拍成功没有」,再多只是把页面拉长。
const maxShotRecords = 20
// ShotRecord 是一次截图的**统计事实**(不含路径、不含内容)。
type ShotRecord struct {
// At 是截图落盘时间。
At time.Time
// Bytes 是落盘字节数。
Bytes int
// Width / Height 是真实像素尺寸;DimensionsKnown=false 时这两个数不可信。
Width int
Height int
// DimensionsKnown 报告宽高是否从字节里解出来。
DimensionsKnown bool
// Format 是实际编码("png" / "jpeg")。
Format string
// Degraded 报告这张图是否是「PNG 超上限、降级成 JPEG」的产物。
Degraded bool
}
// StateSnapshot 是面板与自查工具要的一组只读事实。
//
// 字段全部是**当前进程里的事实**(配置取自生效配置、可用性取自构造时的探测结论),
// 因此它不会自己发现新装了一个 Chrome 或刚改过的配置文件 —— 那需要重启或面板保存
// 触发的热重载,两种情形都会重建 Manager,快照随之更新。
type StateSnapshot struct {
// Source 是来源描述(本机有头 / 容器端点),不含凭据。
Source string
// Available 报告当前有没有可用来源(不可用时工具根本不会被注册)。
Available bool
// Closed 报告会话管理器是否已关闭(`Close` 之后)。
Closed bool
// Headless 是生效档(false = 有头,design/28 §2 的默认档)。
Headless bool
// ViewportWidth / ViewportHeight 是生效视口。
ViewportWidth int
ViewportHeight int
// ProfileDir 是持久化 profile 目录(含登录态,0700;**不进上下文**)。
ProfileDir string
// ShotsDir 是截图落点目录。
ShotsDir string
// Concurrency 是生效并发上限:**恒为 1**(同一 profile 起两个实例会互锁,
// design/28 §3)。面板如实显示它,而不是把它当可调项。
Concurrency int
// TimeoutSeconds / IdleTimeoutSeconds 是动作超时与空闲回收秒数。
TimeoutSeconds int
IdleTimeoutSeconds int
// ScreenshotMaxBytes 是单张截图字节上限。
ScreenshotMaxBytes int
// SessionLive 报告此刻是否有一个活着的浏览器会话。
SessionLive bool
// SessionOpenedAt 是当前会话的建立时间(SessionLive=false 时为零值)。
SessionOpenedAt time.Time
// Shots 是最近截图统计,**最新在前**。
Shots []ShotRecord
// Fingerprint / FingerprintAt 是最后一次指纹自检的读数与时间;
// 从未自检过时 Fingerprint 为 nil(面板如实显示「未自检」,不编造)。
Fingerprint *Fingerprint
FingerprintAt time.Time
}
// State 返回当前只读事实快照。它不启动浏览器、不发网络请求(见文件头口径 1)。
func (m *Manager) State() StateSnapshot {
if m == nil {
return StateSnapshot{}
}
m.mu.Lock()
defer m.mu.Unlock()
out := StateSnapshot{
Source: m.source,
Available: m.d != nil,
Closed: m.closed,
Headless: m.cfg.headless,
ViewportWidth: m.cfg.viewportW,
ViewportHeight: m.cfg.viewportH,
ProfileDir: m.cfg.profileDir,
ShotsDir: m.cfg.shotsDir,
Concurrency: browsercfg.DefaultConcurrency,
TimeoutSeconds: int(m.cfg.timeout / time.Second),
IdleTimeoutSeconds: int(m.cfg.idle / time.Second),
ScreenshotMaxBytes: m.cfg.shotMaxBytes,
SessionLive: m.sess != nil,
SessionOpenedAt: m.sessionOpenedAt,
Fingerprint: cloneFingerprint(m.fp),
FingerprintAt: m.fpAt,
}
out.Shots = append([]ShotRecord(nil), m.shots...)
return out
}
// recordShotLocked 记一条截图统计(最新在前、最多 maxShotRecords 条)。调用方必须持有 m.mu。
func (m *Manager) recordShotLocked(shot Shot, at time.Time) {
rec := ShotRecord{
At: at,
Bytes: shot.Bytes,
Width: shot.Width,
Height: shot.Height,
DimensionsKnown: shot.DimensionsKnown,
Format: shot.Format,
Degraded: shot.Degraded,
}
m.shots = append([]ShotRecord{rec}, m.shots...)
if len(m.shots) > maxShotRecords {
m.shots = m.shots[:maxShotRecords]
}
}
// recordFingerprint 记下最后一次指纹自检的读数(面板据此显示「上次自检」的时间与读数)。
func (m *Manager) recordFingerprint(fp Fingerprint) {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.fp = &fp
m.fpAt = time.Now()
}
// cloneFingerprint 深拷一份指纹读数(含切片),使 `State` 的返回值与内部状态互不影响。
func cloneFingerprint(fp *Fingerprint) *Fingerprint {
if fp == nil {
return nil
}
out := *fp
out.Languages = append([]string(nil), fp.Languages...)
return &out
}
+145
View File
@@ -0,0 +1,145 @@
package browser
// 本文件钉住 B4 面板的**驱动层事实面**(design/28 §8 B4;形状见 state.go):
// 取数**不启动浏览器**、最近截图统计有界且最新在前、指纹缓存是深拷。
//
// 「面板取数无副作用」这条是硬口径:面板的只读区块连 viewer 都能看(design/09 §5.4.1),
// 让「打开页面看一眼」顺手起一个 Chrome 是明确的越界。因此这里用**计数型假 dialer**
// 证明 `State()` 一次都没碰过启动路径(比「看起来没起」强)。
import (
"context"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// countingDialer 记录 Dial 调用次数:任何一次 `State()` 都不该让它 +1。
type countingDialer struct {
dials atomic.Int32
}
func (d *countingDialer) Dial(context.Context) (string, func(), error) {
d.dials.Add(1)
return "", func() {}, nil
}
func (d *countingDialer) Describe() string { return "counting-dialer" }
func (d *countingDialer) Local() bool { return false }
// newStateFixture 造一个带可用 dialer 的管理器(配置走默认值)。
func newStateFixture(t *testing.T) (*Manager, *countingDialer) {
t.Helper()
m := NewManager(Options{
Availability: browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Detail: "fixture", Headless: true,
},
Config: browsercfg.Defaults(`C:\data`),
DataDir: `C:\data`,
})
t.Cleanup(func() { _ = m.Close() })
d := &countingDialer{}
m.d = d
return m, d
}
// TestManagerState_ReadsFactsWithoutLaunching 钉住「取数无副作用」:State() 只读字段,
// 一次都不碰启动路径。
func TestManagerState_ReadsFactsWithoutLaunching(t *testing.T) {
t.Parallel()
m, d := newStateFixture(t)
for i := 0; i < 3; i++ {
snap := m.State()
if !snap.Available {
t.Fatalf("可用来源下 Available 应为 true:%+v", snap)
}
if snap.SessionLive {
t.Fatalf("State() 建出了会话:%+v", snap)
}
if snap.Headless != browsercfg.DefaultHeadless {
t.Errorf("Headless = %v, want %v", snap.Headless, browsercfg.DefaultHeadless)
}
if snap.ViewportWidth != browsercfg.DefaultViewportWidth || snap.ViewportHeight != browsercfg.DefaultViewportHeight {
t.Errorf("视口 = %dx%d", snap.ViewportWidth, snap.ViewportHeight)
}
if snap.Concurrency != browsercfg.DefaultConcurrency {
t.Errorf("并发上限 = %d, want %d(profile 锁)", snap.Concurrency, browsercfg.DefaultConcurrency)
}
if snap.Shots != nil {
t.Errorf("没有截图时 Shots 应为空:%+v", snap.Shots)
}
}
if got := d.dials.Load(); got != 0 {
t.Fatalf("State() 触发了 %d 次拨号(面板取数必须无进程外副作用)", got)
}
}
// TestManagerState_ShotRingIsBoundedAndNewestFirst 钉住最近截图统计的形状:
// 有界(不随运行时间无界增长)且最新在前(面板第一行是「刚才那一下」)。
func TestManagerState_ShotRingIsBoundedAndNewestFirst(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
base := time.Date(2026, 10, 6, 21, 0, 0, 0, time.UTC)
for i := 0; i < maxShotRecords+5; i++ {
m.mu.Lock()
m.recordShotLocked(Shot{Bytes: i + 1, Width: 8, Height: 8, DimensionsKnown: true, Format: "png"}, base.Add(time.Duration(i)*time.Second))
m.mu.Unlock()
}
shots := m.State().Shots
if len(shots) != maxShotRecords {
t.Fatalf("保留条数 = %d, want %d", len(shots), maxShotRecords)
}
if shots[0].Bytes != maxShotRecords+5 {
t.Fatalf("最新的一条不在最前:首条字节 = %d", shots[0].Bytes)
}
for i := 1; i < len(shots); i++ {
if !shots[i-1].At.After(shots[i].At) {
t.Fatalf("行序不是最新在前:%d 与 %d", i-1, i)
}
}
}
// TestManagerState_FingerprintIsCachedByValue 钉住指纹缓存是**深拷**:调用方改了返回的
// 读数不该改到管理器里的那一份(否则面板显示会被别人的改动悄悄污染)。
func TestManagerState_FingerprintIsCachedByValue(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
m.recordFingerprint(Fingerprint{Webdriver: false, Languages: []string{"zh-CN", "en"}, Timezone: "Asia/Shanghai"})
first := m.State()
if first.Fingerprint == nil || first.FingerprintAt.IsZero() {
t.Fatalf("指纹没被记下来:%+v", first)
}
first.Fingerprint.Languages[0] = "mutated"
if got := m.State().Fingerprint.Languages[0]; got != "zh-CN" {
t.Fatalf("指纹缓存被外部改动污染了:%q", got)
}
}
// TestManagerState_ClosedIsReported 钉住「已关闭」如实上报(面板据此显示「已关闭」而不是
// 假装还可用 —— 关闭之后任何动作都会报 ErrNoSession)。
func TestManagerState_ClosedIsReported(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
if err := m.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
snap := m.State()
if !snap.Closed {
t.Fatalf("关闭后 Closed = false:%+v", snap)
}
if snap.SessionLive {
t.Error("关闭后不该报会话活着")
}
}
// TestManagerState_NilManagerIsSafe 钉住 nil 管理器的兜底(面板在「工具没注册」那条路上
// 仍要取事实)。
func TestManagerState_NilManagerIsSafe(t *testing.T) {
t.Parallel()
var m *Manager
if snap := m.State(); snap.Available || snap.Closed {
t.Fatalf("nil 管理器的快照 = %+v", snap)
}
}
+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")
}
}