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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user