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:
1 parent
4213b268fe
commit
e0a6bca501
18 files changed
+3698
-72
No files matched your search
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user