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

+170
View File
@@ -0,0 +1,170 @@
package browser
import (
"context"
"strings"
"sync"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件是 design/28 §7 判据 2(M4 闭环)的**浏览器侧**:截图一落盘就把它挂进
// 「**本回合待送出的截图**」附件箱,让循环的装配钩子在同一回合的下一次模型调用里取走
// (钩子在 `cmd/cyrene/wire_browser_context.go`,取走时按主模型能力决定给真图还是
// 工具形态的文字 —— 判定与内联都在 `internal/runtime/multimodal/vision`)。
//
// 为什么要一个「箱」而不是让工具自己把图塞进模型请求:工具拿到的是**一次调用**的
// context,它能改的只有自己的返回值(工具结果是**文本**,见 `internal/runtime/loop/tools.go`
// 的 `toolResultMessage`),而「挂一张图到本轮视图」是**循环**的职责(消息归属、
// 角色合法性、checkpoint 一致性都在那边)。两边各让半步:工具交图,循环把它变成附件。
//
// 三条口径:
//
// 1. **按会话隔离**:箱子按 `loop.SessionFromContext(ctx)` 的会话 id 分桶 —— 真并行
// 多路时,A 会话拍的图绝不会跑到 B 会话的上下文里(跨端串话是本项目踩过的坑);
// 拿不到会话 id(子代理 / 未接编排器的调用)时**不入箱**,如实报
// `turnAttachment=no-session`,由模型自己按路径去读;
// 2. **有界 + 有寿命**:每个会话最多 `maxPendingShots` 张、`shotOutboxTTL` 内有效。
// 截图是**要送出去**的东西,不是缓存:攒着不送只会把上下文撑大(一张 5 MiB 的图
// 进 base64 后约 7 M 字符)。超上限时丢**最旧**的那张并如实计数(`Dropped`);
// 3. **取走即消费**:`Drain` 把该会话的待送图整体取走并清空(循环每步取一次),
// 因此同一张图不会被送到两次(重复送图既费预算又会让模型以为「又拍了一张」)。
type ShotOutbox struct {
mu sync.Mutex
bySess map[string][]pendingShot
dropped map[string]int
now func() time.Time
}
// pendingShot 是箱里的一格:截图 + 入箱时间(TTL 判据)。
type pendingShot struct {
shot browsercap.Shot
at time.Time
}
// 箱子的具名上限。
const (
// maxPendingShots 是**每个会话**最多攒着的截张数。一次任务里连拍 4 张还没送出去
// 说明装配钩子没在跑(或模型在狂拍),此时丢最旧的一张比把 4 张 5 MiB 的图
// 一起塞进下一个请求更负责。
maxPendingShots = 4
// shotOutboxTTL 是待送截图的有效期。超过它的图不再送出(会话可能已经结束),
// 文件仍在盘上,模型按路径照样能读。
shotOutboxTTL = 10 * time.Minute
)
// 工具结果里 `turnAttachment` 的取值(闭集):它让**模型与面板**都能看出这张图到底
// 有没有进上下文,而不是只在日志里。
const (
// TurnAttachmentQueued 报告截图已入箱,将在本回合的下一次模型调用里作为附件送出。
TurnAttachmentQueued = "queued"
// TurnAttachmentNoSession 报告本次调用拿不到会话 id(子代理 / 未接编排器),未入箱。
TurnAttachmentNoSession = "no-session"
// TurnAttachmentFull 报告箱已满,最旧的一张被丢弃(如实计数,不静默)。
TurnAttachmentFull = "outbox-full"
// TurnAttachmentDisabled 报告本工具没有附件箱(`New(nil)` 这类夹具),未入箱。
TurnAttachmentDisabled = "disabled"
)
// NewShotOutbox 造一个空箱子。零值不可用(map 未初始化),一律经本函数构造 ——
// 与 `New` 里默认建箱是同一个实现,不存在第二份。
func NewShotOutbox() *ShotOutbox {
return &ShotOutbox{
bySess: make(map[string][]pendingShot),
dropped: make(map[string]int),
now: time.Now,
}
}
// Put 把一张截图放进 sessionID 的格子。返回本次入箱的结论(TurnAttachment*)。
// sessionID 为空 ⇒ 谁也不放(调用方据此如实报 no-session)。
func (o *ShotOutbox) Put(sessionID string, shot browsercap.Shot) string {
if o == nil {
return TurnAttachmentDisabled
}
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return TurnAttachmentNoSession
}
o.mu.Lock()
defer o.mu.Unlock()
o.expireLocked(sessionID)
list := append(o.bySess[sessionID], pendingShot{shot: shot, at: o.now()})
status := TurnAttachmentQueued
if len(list) > maxPendingShots {
// 丢最旧的:新的那张更接近「模型现在想问的那一页」。
list = list[len(list)-maxPendingShots:]
o.dropped[sessionID]++
status = TurnAttachmentFull
}
o.bySess[sessionID] = list
return status
}
// Drain 取走并清空 sessionID 的待送截图(取走即消费)。会话 id 为空或没有待送图时
// 返回 nil —— 那是常态(工具批次之间通常没有新截图),不是错误。
func (o *ShotOutbox) Drain(sessionID string) []browsercap.Shot {
if o == nil {
return nil
}
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil
}
o.mu.Lock()
defer o.mu.Unlock()
o.expireLocked(sessionID)
list := o.bySess[sessionID]
delete(o.bySess, sessionID)
delete(o.dropped, sessionID)
if len(list) == 0 {
return nil
}
out := make([]browsercap.Shot, 0, len(list))
for _, p := range list {
out = append(out, p.shot)
}
return out
}
// Pending 报告某会话当前攒着几张待送截图(面板/诊断用;不消费)。
func (o *ShotOutbox) Pending(sessionID string) int {
if o == nil {
return 0
}
o.mu.Lock()
defer o.mu.Unlock()
o.expireLocked(sessionID)
return len(o.bySess[sessionID])
}
// expireLocked 丢掉该会话里过期的格子(TTL 之外)。调用方必须持有 o.mu。
func (o *ShotOutbox) expireLocked(sessionID string) {
list := o.bySess[sessionID]
if len(list) == 0 {
return
}
cutoff := o.now().Add(-shotOutboxTTL)
kept := list[:0]
for _, p := range list {
if p.at.After(cutoff) {
kept = append(kept, p)
}
}
if len(kept) == 0 {
delete(o.bySess, sessionID)
return
}
o.bySess[sessionID] = kept
}
// PutForContext 按 ctx 上的会话 id 入箱(工具在自己的 Execute 里用这一个入口:
// 会话 id 的来源只有 `loop.SessionFromContext` 一处,绝不从参数或模型输出推断)。
func (o *ShotOutbox) PutForContext(ctx context.Context, shot browsercap.Shot) string {
if o == nil {
return TurnAttachmentDisabled
}
return o.Put(loop.SessionFromContext(ctx), shot)
}
+171
View File
@@ -0,0 +1,171 @@
package browser
// 本文件钉住 B3 闭环的**浏览器侧**(design/28 §7 判据 2、切片 Y4):截图落盘后被放进
// 「本回合待送出」的附件箱,且**按会话隔离、取走即消费、有界**;没有会话 id 时如实报
// no-session 而不是丢进一个谁都能拿的公共格子。
//
// 视觉侧(图怎么变成 image 部件 / vision 工具文字)由
// `internal/runtime/multimodal/vision/shot_test.go` 覆盖,两处不重复。
import (
"context"
"strings"
"testing"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
)
// fakeShot 造一张与落盘结论同形的截图(本文件不碰真浏览器)。
func fakeShot(path string) browsercap.Shot {
return browsercap.Shot{Path: path, Bytes: 1024, SHA256: "abc", Format: "png",
Width: 1280, Height: 800, DimensionsKnown: true}
}
// TestShotOutbox_PerSessionAndDrainOnce 钉住「按会话隔离 + 取走即消费」:
// A 会话的截图绝不出现在 B 会话的取用结果里,且同一张只能被送出一次。
func TestShotOutbox_PerSessionAndDrainOnce(t *testing.T) {
t.Parallel()
box := NewShotOutbox()
if got := box.Put("sess-a", fakeShot("/a.png")); got != TurnAttachmentQueued {
t.Fatalf("Put = %q, want %q", got, TurnAttachmentQueued)
}
if n := box.Pending("sess-b"); n != 0 {
t.Fatalf("B 会话不该看到 A 的截图,Pending=%d", n)
}
if got := box.Drain("sess-b"); got != nil {
t.Fatalf("B 会话 Drain = %v, want nil", got)
}
drained := box.Drain("sess-a")
if len(drained) != 1 || drained[0].Path != "/a.png" {
t.Fatalf("Drain = %+v", drained)
}
if again := box.Drain("sess-a"); again != nil {
t.Fatalf("第二次 Drain 应当为空(取走即消费):%+v", again)
}
}
// TestShotOutbox_NoSessionIsHonest 钉住「拿不到会话 id 就不入箱」:子代理 / 未接编排器的
// 调用必须如实报 no-session(图仍在盘上,模型按路径自己读),而不是丢进一个全局格子。
//
// ⚠️ 这里只测「没有会话」这一档:把会话 id 播种到 ctx 上的**唯一**生产者是
// `loop.Run`(`withLoopSession` 不导出),因此「有会话时真的入箱」由组合根的真实循环
// 用例覆盖(`cmd/cyrene/wire_browser_context_test.go`),本包不伪造那条通道。
func TestShotOutbox_NoSessionIsHonest(t *testing.T) {
t.Parallel()
box := NewShotOutbox()
if got := box.Put("", fakeShot("/x.png")); got != TurnAttachmentNoSession {
t.Fatalf("空会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession)
}
if got := box.Put(" ", fakeShot("/x.png")); got != TurnAttachmentNoSession {
t.Fatalf("空白会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession)
}
if got := box.PutForContext(context.Background(), fakeShot("/x.png")); got != TurnAttachmentNoSession {
t.Fatalf("ctx 上没有会话时的 PutForContext = %q, want %q", got, TurnAttachmentNoSession)
}
if n := box.Pending(""); n != 0 {
t.Fatalf("空会话不该攒下任何东西:Pending=%d", n)
}
}
// TestShotOutbox_BoundedAndTTL 钉住有界与寿命:超过上限丢**最旧**的一张并如实报
// outbox-full;超过 TTL 的截图不再送出(会话可能早已结束)。
func TestShotOutbox_BoundedAndTTL(t *testing.T) {
t.Parallel()
box := NewShotOutbox()
base := time.Now()
box.now = func() time.Time { return base }
for i := 0; i < maxPendingShots; i++ {
if got := box.Put("s", fakeShot(string(rune('a'+i)))); got != TurnAttachmentQueued {
t.Fatalf("第 %d 张 Put = %q", i, got)
}
}
if got := box.Put("s", fakeShot("overflow")); got != TurnAttachmentFull {
t.Fatalf("超上限的 Put = %q, want %q", got, TurnAttachmentFull)
}
got := box.Drain("s")
if len(got) != maxPendingShots {
t.Fatalf("攒着的张数 = %d, want %d", len(got), maxPendingShots)
}
if got[len(got)-1].Path != "overflow" {
t.Errorf("最新的一张应当留下:%+v", got)
}
// TTL:过期的格子被清掉(文件仍在盘上,只是不再送出)。
box.Put("t", fakeShot("/old.png"))
box.now = func() time.Time { return base.Add(shotOutboxTTL + time.Minute) }
if n := box.Pending("t"); n != 0 {
t.Errorf("过期截图仍在箱里:Pending=%d", n)
}
}
// TestTool_ScreenshotQueuesShotForTheTurn 钉住工具面的接线:带会话 ctx 的截图动作把图
// 放进箱子并如实报 queued,同时**仍然**返回路径与统计(模型/面板都能按路径再读)。
//
// 会话来自 `loop.Run`(本包造不出来),因此这里经**箱子本身**验证注入点:工具用的是
// 同一个箱子实例(`WithShotOutbox` 注入 → `ShotOutbox()` 取回),真实循环链路由组合根
// 用例端到端覆盖。
func TestTool_ScreenshotQueuesShotForTheTurn(t *testing.T) {
t.Parallel()
drv := &fakeDriver{available: true, shot: fakeShot("/shots/s1.png")}
injected := NewShotOutbox()
injected.Put("sess-shot", fakeShot("/preset.png"))
tool := New(drv, WithShotOutbox(injected))
if tool.ShotOutbox() != injected {
t.Fatal("WithShotOutbox 注入的箱子没被采用:装配钩子将取不到截图")
}
// 工具在**没有会话**的 ctx 下动作:图仍落盘,附件状态如实报 no-session。
res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m, ok := res.(map[string]any)
if !ok {
t.Fatalf("结果类型 = %T", res)
}
if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession {
t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession)
}
if got, _ := m["path"].(string); got != "/shots/s1.png" {
t.Fatalf("path = %q", got)
}
if hint, _ := m["hint"].(string); hint == "" {
t.Error("hint 为空:模型不知道这张图进没进上下文")
}
// 预先放进箱子的那张仍在(本次调用没有会话,因此没有消费它)。
if got := tool.ShotOutbox().Drain("sess-shot"); len(got) != 1 || got[0].Path != "/preset.png" {
t.Fatalf("箱子里的截图 = %+v", got)
}
}
// TestTool_ScreenshotWithoutSessionIsHonest 钉住没有会话时的行为:动作照样成功(图落盘),
// 但 `turnAttachment` 如实报 no-session,且提示文案改为「按路径自己读」。
func TestTool_ScreenshotWithoutSessionIsHonest(t *testing.T) {
t.Parallel()
drv := &fakeDriver{available: true, shot: fakeShot("/shots/s2.png")}
tool := New(drv)
res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := res.(map[string]any)
if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession {
t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession)
}
if hint, _ := m["hint"].(string); hint == "" || !strings.Contains(hint, "NOT attached") {
t.Errorf("没有会话时的提示必须说清「没进上下文」:%q", hint)
}
}
// TestTool_StateExposesFactsWithoutDriver 钉住面板取数的兜底:驱动不支持事实面
// (假驱动)时返回零值快照,而不是 panic —— 面板据此显示「未知」。
func TestTool_StateExposesFactsWithoutDriver(t *testing.T) {
t.Parallel()
if got := New(nil).State(); got.Available {
t.Errorf("nil 驱动的事实面 = %+v,应当是零值", got)
}
if got := (*Tool)(nil).State(); got.Available {
t.Errorf("nil 工具的事实面 = %+v", got)
}
}
+258
View File
@@ -0,0 +1,258 @@
package browser
// 本文件是 `browser` 插件向**面板**声明的管理页面(design/09 §5.2 的 `adminPanels`,
// design/28 §8 B4 的落点):形状即 `pkg/plugins/sdk` 的 `Panel`。
//
// 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它
// - 不注入任何 JS/CSS/HTML、
// - 不给 URL(只有动作名,走宿主的通用动作端点分发)、
// - 不开任何路由。
// 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、
// 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。
//
// 页面构成(一处读全):
//
// ① form 浏览器配置 —— 有头/无头、视口、profile 目录、并发上限…(保存即热重载)
// ② table 运行状态 —— 只读事实面 + 行内动作「拍一张(当前页面)」
// ③ table 指纹自检 —— 只读事实面 + 行内动作「重新自检」(唯一会起浏览器的读面动作)
// ④ table 最近截图 —— 只统计(字节/尺寸/格式),不含路径与内容
//
// 为什么状态/自检/截图都做成 **table** 而不是 `status`:`status` 区块按 §5.2.1 的接入点表
// **不消费 `source`**(`paneldecl.shapeOf` 明写 notAllowed),没有来源就没有活数据 —— 硬要
// 用 status 只能给一份声明期的静态文本,那才是真正的「看起来有、其实没有」。表格是
// 声明面里唯一既有来源又能带按钮的形状,因此三块活数据都用它(同为一处口径)。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**:
// `$DATA_DIR/config/plugins/browser.json`(design/13 §2.1:文件名 = 插件 id),
// 两边由 panel_test.go 钉住。
const PanelPluginID = "browser"
// PanelIDSettings 是「浏览器与截图」页的面板 id。
// 前端路由为 `/admin/plugin/browser/settings`(design/09 §5.7 C)。
const PanelIDSettings = "settings"
// 本插件声明的**全部**动作名(design/09 §5.2.1 的动作面)。
//
// 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403)。
// 把「声明」与「动作名」放同一个文件,是为了让「表里写了一个没实现的动作」这种半渲染
// 在 review 时一眼可见 —— 它们就挨着。
const (
// ActionState 是运行状态只读来源(表格数据源)。**不启动浏览器**。
ActionState = "plugin.getBrowserState"
// ActionShots 是最近截图只读来源(表格数据源)。**不读文件、不含内容**。
ActionShots = "plugin.listShots"
// ActionFingerprint 是最后一次指纹自检的只读来源(表格数据源),未自检过时如实说「未自检」。
ActionFingerprint = "plugin.getFingerprint"
// ActionSaveConfig 保存浏览器配置(表单提交)。落 `$DATA_DIR/config/plugins/browser.json`
// 并**热重载**工具(design/09 §5.5 的口径:面板的意义就是不改 `.env`、不重启)。
ActionSaveConfig = "plugin.saveBrowserConfig"
// ActionShoot 拍一张(行内动作)。owner-only + 落审计(design/28 §1 M3)。
ActionShoot = "plugin.shootPage"
// ActionCheck 重新做一次指纹自检(行内动作)。它会**启动浏览器** —— 这是本页唯一有
// 进程外副作用的动作,因此是显式按钮而绝非「打开页面就顺手跑」。
ActionCheck = "plugin.checkFingerprint"
)
// ActionNames 返回本插件声明的动作名(顺序与 AdminPanels 里出现的顺序一致:写表单 →
// 三个读来源 → 两个行内动作)。调用方(插件管理器的动作端点)据此建白名单:
// 白名单外的动作名一律拒,绝不「先执行再看结果」。
func ActionNames() []string {
return []string{
ActionSaveConfig,
ActionState, ActionFingerprint, ActionShots,
ActionShoot, ActionCheck,
}
}
// 表里三块只读数据的列 key(与 paneldata.go 的行字段同源:声明一个 key、行里写另一个,
// 表现就是「表头在、数据空」—— 所以两边只允许引用常量)。
const (
// 运行状态列
colSource = "source"
colAvailable = "available"
colHeadless = "headless"
colViewport = "viewport"
colProfileDir = "profileDir"
colConcurrent = "concurrency"
colIdleReco = "idleTimeoutSeconds"
colShotCap = "shotMaxBytes"
// 指纹自检列
colWebdriver = "webdriver"
colScreen = "screen"
colTimezone = "timezone"
colLanguage = "language"
colUserAgent = "userAgent"
colCheckedAt = "checkedAt"
// 最近截图列
colShotAt = "at"
colShotBytes = "bytes"
colShotSize = "size"
colShotFormat = "format"
colShotDegraded = "degraded"
)
// 表单字段 key(与 `browsercfg.Config` 的 JSON tag 逐字一致 —— 字段名与文件字段名对上,
// 面板写的键就是运行期读的键)。
const (
FieldHeadless = "headless"
FieldExecutable = "executable"
FieldCDPEndpoint = "cdpEndpoint"
FieldProfileDir = "profileDir"
FieldViewportWidth = "viewportWidth"
FieldViewportHeight = "viewportHeight"
FieldIdleTimeout = "idleTimeoutSeconds"
FieldConcurrency = "concurrency"
)
// PanelInput 是声明生成需要的**当前事实**:配置(给表单默认值)与可用性(决定描述文案
// 是否如实说明「这台机器现在没有浏览器」)。
//
// 为什么默认值要由调用方传入而不是读文件:声明是**装配期**生成的(组合根一处),
// 读文件是它唯一的事实来源;把「读哪份配置」的判断塞进声明函数会让「面板显示的」与
// 「工具真的用的」两处各自算一次。
type PanelInput struct {
// Config 是**当前生效**的配置(已 Normalize)。
Config browsercfg.Config
// Available 报告当前有没有可用来源。
Available bool
// Source 是来源描述(本机 / 容器端点),只进描述文案。
Source string
}
// AdminPanels 返回本插件声明的管理页面。
func AdminPanels(in PanelInput) []sdk.Panel {
return []sdk.Panel{{
ID: PanelIDSettings,
Title: "浏览器与截图",
Description: panelDescription(in),
Group: sdk.GroupPlugins,
Order: 30,
Icon: "browser",
// 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。browser 是编进二进制的
// 内置能力,没有卸载路径 ⇒ 恒为启用;写条件是为了让「启用事实」由插件管理器给出。
When: &sdk.When{Enabled: boolFlag(true)},
Sections: []sdk.Section{
configSection(in.Config),
stateSection(),
fingerprintSection(),
shotsSection(),
},
}}
}
// panelDescription 拼页面描述。不可用时**如实说明**(照 web-search `off` 的诚实口径:
// 面板可以配,配好保存后热重载生效;没有来源时工具不会注册)。
func panelDescription(in PanelInput) string {
base := "配置这个浏览器(有头/无头、视口、profile 目录、并发上限),查看运行状态与指纹自检," +
"并拍一张当前页面。改动落 $DATA_DIR/config/plugins/browser.json,**保存后立即生效**" +
"(工具会按新配置重建,无需重启)。"
if in.Available {
return base + "当前来源:" + in.Source + "。"
}
return base + "⚠️ 当前**没有**可用来源(未探测到本机 Chrome/Chromium,也没有在跑的 CDP 端点):" +
"`browser` 工具此刻**不在**模型的能力面里;在这里配好路径/端点并保存,重建后即生效。"
}
// configSection 是配置表单:字段与 `browsercfg.Config` 同源,默认值取当前生效值
// (面板因此「打开即显示现状」)。
func configSection(cfg browsercfg.Config) sdk.Section {
one := 1.0
return sdk.Section{
Type: sdk.SectionForm,
ID: "config",
Title: "浏览器配置",
Fields: []sdk.Field{
{Key: FieldHeadless, Label: "无头模式", Type: sdk.FieldSwitch, Default: cfg.Headless,
Placeholder: "关 = 有头(默认;过风控的主路径)"},
{Key: FieldViewportWidth, Label: "视口宽", Type: sdk.FieldNumber, Default: cfg.ViewportWidth,
Min: floatPtr(320), Max: floatPtr(float64(browsercfg.MaxViewportWidth))},
{Key: FieldViewportHeight, Label: "视口高", Type: sdk.FieldNumber, Default: cfg.ViewportHeight,
Min: floatPtr(240), Max: floatPtr(float64(browsercfg.MaxViewportHeight))},
{Key: FieldProfileDir, Label: "profile 目录", Type: sdk.FieldText, Default: cfg.ProfileDir,
Placeholder: "含 cookie/登录态;默认 $DATA_DIR/browser/profile"},
{Key: FieldConcurrency, Label: "并发上限", Type: sdk.FieldNumber, Default: cfg.Concurrency,
Min: &one, Max: &one,
Placeholder: "固定 1:同一 profile 起两个实例会互锁"},
{Key: FieldIdleTimeout, Label: "空闲回收(秒)", Type: sdk.FieldNumber, Default: cfg.IdleTimeoutSeconds,
Min: floatPtr(1)},
{Key: FieldExecutable, Label: "浏览器可执行文件", Type: sdk.FieldText, Default: cfg.Executable,
Placeholder: "留空 = 自动探测本机 Chrome/Chromium"},
{Key: FieldCDPEndpoint, Label: "CDP 端点", Type: sdk.FieldText, Default: cfg.CDPEndpoint,
Placeholder: "如 http://127.0.0.1:9222(显式给出时取代自动探测)"},
},
Submit: &sdk.Submit{Action: ActionSaveConfig},
}
}
// stateSection 是「运行状态」:只读事实面 + 行内动作「拍一张」。
func stateSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "state",
Title: "运行状态",
Source: &sdk.Source{Action: ActionState},
Columns: []sdk.Column{
{Key: colSource, Label: "来源"},
{Key: colAvailable, Label: "可用"},
{Key: colHeadless, Label: "无头"},
{Key: colViewport, Label: "视口"},
{Key: colProfileDir, Label: "profile"},
{Key: colConcurrent, Label: "并发"},
{Key: colIdleReco, Label: "空闲回收(秒)"},
{Key: colShotCap, Label: "截图上限(字节)"},
},
RowActions: []sdk.RowAction{
{ID: "shoot", Label: "拍一张(当前页面)", Action: ActionShoot},
},
}
}
// fingerprintSection 是「指纹自检」:默认只显示**上一次**读数(不启动浏览器),
// 要刷新得点行内动作(那是显式的、有进程外副作用的动作)。
func fingerprintSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "fingerprint",
Title: "指纹自检",
Source: &sdk.Source{Action: ActionFingerprint},
Columns: []sdk.Column{
{Key: colWebdriver, Label: "webdriver"},
{Key: colScreen, Label: "屏幕"},
{Key: colTimezone, Label: "时区"},
{Key: colLanguage, Label: "语言"},
{Key: colUserAgent, Label: "User-Agent"},
{Key: colCheckedAt, Label: "自检时间"},
},
RowActions: []sdk.RowAction{
{ID: "check", Label: "重新自检(会启动浏览器)", Action: ActionCheck},
},
}
}
// shotsSection 是「最近截图」:只有统计(时间/字节/尺寸/格式),**不含路径与内容**。
func shotsSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "shots",
Title: "最近截图",
Source: &sdk.Source{Action: ActionShots},
Columns: []sdk.Column{
{Key: colShotAt, Label: "时间"},
{Key: colShotBytes, Label: "字节"},
{Key: colShotSize, Label: "尺寸"},
{Key: colShotFormat, Label: "格式"},
{Key: colShotDegraded, Label: "降级"},
},
}
}
// boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。
func boolFlag(v bool) *bool { return &v }
// floatPtr 是 `&1.0` 的简写(sdk.Field.Min/Max 是指针,缺省与「显式 0」不同义)。
func floatPtr(v float64) *float64 { return &v }
+306
View File
@@ -0,0 +1,306 @@
package browser
// 本文件钉住 B4 面板的**声明面与数据面**(design/09 §5.2/§5.2.1、design/28 §8 B4):
// 声明能过宿主闭集校验、动作名与声明同源、行 key 与列 key 同源、只读区块里没有
// 任何会让 viewer 看到浏览内容的字段。
//
// 「动作真能分发 + 读写面分类正确」用真实面板中枢验证,落在组合根
// (`cmd/cyrene/wire_browser_panel_test.go`):那两件事的判定者不是本包。
import (
"path/filepath"
"strings"
"testing"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// testPanelInput 造一份声明入参(配置取默认值 + 给定可用性)。
func testPanelInput(available bool) PanelInput {
return PanelInput{
Config: browsercfg.Defaults(`C:\data`),
Available: available,
Source: "本机 Chrome(有头)",
}
}
// TestAdminPanelsPassHostValidation 钉住声明能过宿主闭集校验(形状错 ⇒ 整个面板被拒,
// 那是最难排查的一种失败:面板「就是不出现」,而日志里才有原因)。
func TestAdminPanelsPassHostValidation(t *testing.T) {
t.Parallel()
if err := paneldecl.Validate(PanelPluginID, AdminPanels(testPanelInput(true))); err != nil {
t.Fatalf("宿主校验不通过(面板会被整体拒掉): %v", err)
}
if err := paneldecl.Validate(PanelPluginID, AdminPanels(testPanelInput(false))); err != nil {
t.Fatalf("不可用档的声明也必须过校验: %v", err)
}
}
// TestPanelPluginIDMatchesConfigFileName 钉住「文件名 = 插件 id」(design/13 §2.1):
// 两个口径一旦分叉,面板写的配置与工具读的配置就不是同一份。
func TestPanelPluginIDMatchesConfigFileName(t *testing.T) {
t.Parallel()
path := browsercfg.DefaultConfigPath(`C:\data`)
base := filepath.Base(path)
if base != PanelPluginID+".json" {
t.Fatalf("配置文件名 %q 与插件 id %q 不同源", base, PanelPluginID)
}
if dir := filepath.Base(filepath.Dir(path)); dir != "plugins" {
t.Fatalf("配置落点目录 = %q, want plugins", dir)
}
}
// TestAdminPanelsActionsAreDeclaredOnce 钉住「声明里的动作名 ⊆ ActionNames()」且无重复:
// 否则「声明了却没实现」或「白名单漏一个」都会变成前端点了没反应的半渲染。
func TestAdminPanelsActionsAreDeclaredOnce(t *testing.T) {
t.Parallel()
declared := make(map[string]bool)
for _, n := range ActionNames() {
if declared[n] {
t.Fatalf("ActionNames() 里 %q 重复", n)
}
declared[n] = true
}
used := make([]string, 0, len(declared))
for _, p := range AdminPanels(testPanelInput(true)) {
for _, s := range p.Sections {
if s.Submit != nil {
used = append(used, s.Submit.Action)
}
if s.Source != nil {
used = append(used, s.Source.Action)
}
for _, a := range s.RowActions {
used = append(used, a.Action)
}
}
}
if len(used) != len(declared) {
t.Fatalf("声明用了 %d 个动作,ActionNames() 给了 %d 个:%v", len(used), len(declared), used)
}
for _, name := range used {
if !declared[name] {
t.Errorf("声明里的动作 %q 不在 ActionNames() 里", name)
}
}
for _, name := range ActionNames() {
if strings.ContainsAny(name, "/:") {
t.Errorf("动作名 %q 含 URL 成分(§5.4.4:只有动作名,没有 URL)", name)
}
}
}
// TestActionNamesAreTheContract 钉住六个动作名的**字面量**:它们是前端与动作端点之间的
// 契约(前端按声明里的名字调 `POST /admin/plugins/{id}/actions/{action}`),改名会让面板上
// 的按钮静默失效。因此这里写死期望值 —— 改名字必须同时改这里(一个显式的动作)。
func TestActionNamesAreTheContract(t *testing.T) {
t.Parallel()
want := []string{
"plugin.saveBrowserConfig",
"plugin.getBrowserState",
"plugin.getFingerprint",
"plugin.listShots",
"plugin.shootPage",
"plugin.checkFingerprint",
}
got := ActionNames()
if len(got) != len(want) {
t.Fatalf("动作数 = %d, want %d:%v", len(got), len(want), got)
}
for i, name := range want {
if got[i] != name {
t.Errorf("ActionNames()[%d] = %q, want %q", i, got[i], name)
}
}
}
// TestAdminPanelsCarryNoSecretsAndNoURLs 钉住两条红线:声明里没有 `secret` 字段
// (本插件没有凭据面,多一个都说明有人把凭据塞进了面板声明),也没有任何 URL 文本
// (防线:声明里塞不进请求面)。
func TestAdminPanelsCarryNoSecretsAndNoURLs(t *testing.T) {
t.Parallel()
for _, p := range AdminPanels(testPanelInput(true)) {
for _, s := range p.Sections {
for _, f := range s.Fields {
if f.Type == sdk.FieldSecret {
t.Errorf("字段 %q 是 secret:browser 插件的配置里没有凭据面", f.Key)
}
if strings.Contains(f.Placeholder, "http") && f.Key != FieldCDPEndpoint {
t.Errorf("字段 %q 的提示里带 URL(%q)", f.Key, f.Placeholder)
}
}
if s.Source != nil && strings.Contains(s.Source.Action, "://") {
t.Errorf("source 里有 URL: %q", s.Source.Action)
}
}
}
}
// TestConfigSectionDefaultsMirrorCurrentConfig 钉住「打开即显示现状」:表单字段的默认值
// 就是当前生效配置(面板因此不必自己读文件 —— 读的是同一份事实)。
func TestConfigSectionDefaultsMirrorCurrentConfig(t *testing.T) {
t.Parallel()
cfg := browsercfg.Config{
Headless: true,
Executable: `C:\chrome.exe`,
CDPEndpoint: "http://127.0.0.1:9222",
ProfileDir: `C:\data\browser\profile-x`,
ViewportWidth: 1600,
ViewportHeight: 900,
IdleTimeoutSeconds: 120,
Concurrency: 1,
}
cfg = cfg.Normalize(`C:\data`)
want := map[string]any{
FieldHeadless: cfg.Headless,
FieldExecutable: cfg.Executable,
FieldCDPEndpoint: cfg.CDPEndpoint,
FieldProfileDir: cfg.ProfileDir,
FieldViewportWidth: cfg.ViewportWidth,
FieldViewportHeight: cfg.ViewportHeight,
FieldIdleTimeout: cfg.IdleTimeoutSeconds,
FieldConcurrency: cfg.Concurrency,
}
got := map[string]any{}
for _, s := range configSection(cfg).Fields {
got[s.Key] = s.Default
}
for key, v := range want {
if got[key] != v {
t.Errorf("字段 %s 默认值 = %v, want %v", key, got[key], v)
}
}
}
// TestPanelUnavailableDescriptionIsHonest 钉住「没有来源时说明白」:面板描述必须点出
// 工具**此刻不在**模型能力面里(照 web-search `off` 的诚实口径),而不是含糊地说「可能不可用」。
func TestPanelUnavailableDescriptionIsHonest(t *testing.T) {
t.Parallel()
panels := AdminPanels(testPanelInput(false))
desc := panels[0].Description
for _, want := range []string{"没有", "不在"} {
if !strings.Contains(desc, want) {
t.Errorf("不可用档的描述里缺 %q:%s", want, desc)
}
}
okDesc := AdminPanels(testPanelInput(true))[0].Description
if strings.Contains(okDesc, "⚠️") {
t.Errorf("可用档不该出现「没有可用来源」的告警:%s", okDesc)
}
}
// TestPanelDeclaresThreeReadSourcesAndTwoRowActions 钉住页面构成的**形状**:
// 三个读来源(状态/指纹/截图)与两个行内动作(拍一张 / 重新自检),且都没有 `submit`
// (行内动作是写面,submit 是另一条写面,混在一起会让「哪个按钮写了什么」不再可查)。
func TestPanelDeclaresThreeReadSourcesAndTwoRowActions(t *testing.T) {
t.Parallel()
sections := AdminPanels(testPanelInput(true))[0].Sections
sources, rowActions, submits := 0, 0, 0
for _, s := range sections {
if s.Source != nil {
sources++
}
rowActions += len(s.RowActions)
if s.Submit != nil {
submits++
}
}
if sources != 3 || rowActions != 2 || submits != 1 {
t.Fatalf("页面形状 = sources:%d rowActions:%d submits:%d, want 3/2/1", sources, rowActions, submits)
}
}
// 本文件的三个夹具:与驱动层的事实面同形(不含内容,只统计)。
func stateFixture() browsercap.StateSnapshot {
return browsercap.StateSnapshot{
Source: "本机 Chrome(有头)", Available: true, Headless: false,
ViewportWidth: 1280, ViewportHeight: 800, ProfileDir: `C:\datarowser\profile`,
ShotsDir: `C:\datarowser\shots`, Concurrency: 1,
TimeoutSeconds: 30, IdleTimeoutSeconds: 300, ScreenshotMaxBytes: 8 << 20,
}
}
func fingerprintFixture() browsercap.Fingerprint {
return browsercap.Fingerprint{
Headless: false, Webdriver: false, UserAgent: "Mozilla/5.0", Platform: "Win32",
Language: "zh-CN", Languages: []string{"zh-CN", "en"}, Timezone: "Asia/Shanghai",
ScreenWidth: 2560, ScreenHeight: 1440, ColorDepth: 24, DevicePixelRatio: 1,
}
}
func shotRecordFixture() browsercap.ShotRecord {
return browsercap.ShotRecord{
At: time.Date(2026, 10, 6, 21, 30, 0, 0, time.UTC),
Bytes: 123456, Width: 1280, Height: 800, DimensionsKnown: true, Format: "png",
}
}
// TestRowsMatchDeclaredColumns 钉住「行 key 与列 key 同源」:声明一个 key、行里写另一个
// 不会有任何报错,只会让表头下一片空白 —— 只有这类断言拦得住。
func TestRowsMatchDeclaredColumns(t *testing.T) {
t.Parallel()
fp := fingerprintFixture()
sections := AdminPanels(testPanelInput(true))[0].Sections
rows := map[string][]map[string]any{
"state": StateRows(stateFixture()),
"fingerprint": FingerprintRows(&fp, shotRecordFixture().At),
"shots": ShotRows([]browsercap.ShotRecord{shotRecordFixture()}),
}
for _, s := range sections {
if s.Source == nil {
continue
}
got := rows[s.ID]
if len(got) == 0 {
t.Errorf("区块 %s 没有对应的行夹具(用例没跟上声明)", s.ID)
continue
}
for _, row := range got {
for key := range row {
found := false
for _, c := range s.Columns {
if c.Key == key {
found = true
break
}
}
if !found {
t.Errorf("区块 %s 的行里有未声明的列 key %q", s.ID, key)
}
}
}
}
}
// TestUncheckedFingerprintIsHonest 钉住「未自检就写未自检」:面板不拿一组假数字顶替
// (那是「看起来好了」的最经典形态)。
func TestUncheckedFingerprintIsHonest(t *testing.T) {
t.Parallel()
rows := FingerprintRows(nil, time.Time{})
if len(rows) != 1 {
t.Fatalf("未自检时应给一行事实:%+v", rows)
}
for key, v := range rows[0] {
if v != notCheckedText {
t.Errorf("未自检的 %s = %v, want %q", key, v, notCheckedText)
}
}
}
// TestReadOnlyRowsCarryNoPathsOrURLs 钉住隐私口径:只读行里**没有**截图路径(只有统计)。
// 面板的只读区块连 viewer 都能看,把文件路径与截图内容投到那里是越界。
func TestReadOnlyRowsCarryNoPathsOrURLs(t *testing.T) {
t.Parallel()
for _, row := range ShotRows([]browsercap.ShotRecord{shotRecordFixture()}) {
for key, v := range row {
text, _ := v.(string)
if strings.ContainsAny(text, `/\`) {
t.Errorf("截图行 %s 里出现了路径样文本:%v", key, v)
}
}
}
}
+150
View File
@@ -0,0 +1,150 @@
package browser
// 本文件是 `browser` 面板三块只读表格的**行形状**(design/09 §5.2.1 的 section.table):
// 把驱动层的事实面(`internal/capability/browser` 的 `StateSnapshot`)翻成表格行。
//
// 两条硬口径:
//
// 1. **行 key 与声明里的列 key 同源**:两边都引用 panel.go 的 `col*` 常量 —— 声明一个
// key、行里写另一个,表现就是「表头在、数据空」,而且不会有任何报错;
// 2. **不含内容、不含路径、不含 URL**:截图只报字节/尺寸/格式,页面只报「有没有会话」。
// 面板是只读投影(viewer 也能看到表格行),把主人的浏览内容投到那里是越界。
import (
"fmt"
"strconv"
"strings"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
)
// 未自检 / 未知的占位文案。**不编造数字**:没读到的读数就写「未自检」,
// 让面板与真读数一眼可分(design/28 §6 坑 5 的同一条精神:失败如实报)。
const (
notCheckedText = "未自检"
unknownText = "未知"
enabledText = "开"
disabledText = "关"
availableText = "可用"
unavailableText = "不可用"
closedText = "已关闭"
degradedYes = "是(PNG 超限降级 JPEG)"
degradedNo = "否"
)
// StateRows 把运行状态摊成一行(表格是一行事实:这个浏览器现在长什么样)。
//
// 只有一行,因为事实只有一份 —— 多行会诱导读者以为「有多个浏览器在跑」,而并发上限
// 恒为 1(design/28 §3)是这套设计的硬约束。
func StateRows(s browsercap.StateSnapshot) []map[string]any {
availability := availableText
switch {
case s.Closed:
availability = closedText
case !s.Available:
availability = unavailableText
}
source := strings.TrimSpace(s.Source)
if source == "" {
source = unknownText
}
session := "未打开"
if s.SessionLive {
session = "已打开"
if !s.SessionOpenedAt.IsZero() {
session += "(" + formatTime(s.SessionOpenedAt) + ")"
}
}
return []map[string]any{{
colSource: source + "(会话:" + session + ")",
colAvailable: availability,
colHeadless: yesNo(s.Headless, enabledText, disabledText),
colViewport: fmt.Sprintf("%d×%d", s.ViewportWidth, s.ViewportHeight),
colProfileDir: s.ProfileDir,
colConcurrent: strconv.Itoa(s.Concurrency),
colIdleReco: strconv.Itoa(s.IdleTimeoutSeconds),
colShotCap: strconv.Itoa(s.ScreenshotMaxBytes),
}}
}
// FingerprintRows 把最后一次指纹自检摊成一行;从未自检过时给一行**如实**的「未自检」。
func FingerprintRows(fp *browsercap.Fingerprint, at time.Time) []map[string]any {
if fp == nil {
return []map[string]any{{
colWebdriver: notCheckedText,
colScreen: notCheckedText,
colTimezone: notCheckedText,
colLanguage: notCheckedText,
colUserAgent: notCheckedText,
colCheckedAt: notCheckedText,
}}
}
return []map[string]any{{
// webdriver 是**布尔事实**:false 才是正常(我们不传 --enable-automation)。
colWebdriver: yesNo(fp.Webdriver, "true(自动化痕迹没关掉!)", "false(正常)"),
colScreen: fmt.Sprintf("%d×%d @%dbit dpr=%g", fp.ScreenWidth, fp.ScreenHeight, fp.ColorDepth, fp.DevicePixelRatio),
colTimezone: orUnknown(fp.Timezone),
colLanguage: languageText(fp),
colUserAgent: orUnknown(fp.UserAgent),
colCheckedAt: formatTime(at),
}}
}
// ShotRows 把最近截图统计摊成多行(**最新在前**,与驱动的环同序)。
func ShotRows(recs []browsercap.ShotRecord) []map[string]any {
out := make([]map[string]any, 0, len(recs))
for _, r := range recs {
out = append(out, map[string]any{
colShotAt: formatTime(r.At),
colShotBytes: strconv.Itoa(r.Bytes),
colShotSize: sizeText(r),
colShotFormat: orUnknown(r.Format),
colShotDegraded: yesNo(r.Degraded, degradedYes, degradedNo),
})
}
return out
}
// sizeText 渲染像素尺寸。尺寸未知时**不拿视口冒充**(那是「我们以为的」,不是「图里的」)。
func sizeText(r browsercap.ShotRecord) string {
if !r.DimensionsKnown || r.Width <= 0 || r.Height <= 0 {
return unknownText
}
return fmt.Sprintf("%d×%d", r.Width, r.Height)
}
// languageText 渲染语言列表(首项 + 总数,避免长串把单元格撑爆)。
func languageText(fp *browsercap.Fingerprint) string {
if fp.Language == "" && len(fp.Languages) == 0 {
return unknownText
}
if len(fp.Languages) <= 1 {
return orUnknown(fp.Language)
}
return fmt.Sprintf("%s 等 %d 种", orUnknown(fp.Language), len(fp.Languages))
}
// formatTime 渲染时间(秒级可读;零值给「未知」)。
func formatTime(t time.Time) string {
if t.IsZero() {
return unknownText
}
return t.Local().Format("2006-01-02 15:04:05")
}
// orUnknown 把空串渲染成「未知」。
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return unknownText
}
return s
}
// yesNo 按真值返回一对文案。
func yesNo(v bool, yes, no string) string {
if v {
return yes
}
return no
}
+80 -7
View File
@@ -50,6 +50,10 @@ var _ sdk.Tool = (*Tool)(nil)
type Tool struct {
mgr Driver
log *slog.Logger
// outbox 是「本回合待送出的截图」附件箱(B3 闭环的浏览器侧,见 attach.go)。
// 它一定非 nil(New 里默认建一个):nil 箱子会让「截图静默不进上下文」变成
// 一个只能靠读代码才看得出的行为,而它恰恰是本片要修掉的缺口。
outbox *ShotOutbox
}
// Driver 是 Tool 需要的最小驱动面(*browsercap.Manager 实现它)。用接口而不是具体
@@ -65,12 +69,18 @@ type Driver interface {
// New 构造工具;mgr 为 nil 时工具**仍可构造**但每个动作都如实报「不可用」
// (组合根的正确做法是压根不注册它,见 wire_browser.go)。
func New(mgr Driver, opts ...Option) *Tool {
t := &Tool{mgr: mgr, log: discardLog()}
t := &Tool{mgr: mgr, log: discardLog(), outbox: NewShotOutbox()}
for _, opt := range opts {
if opt != nil {
opt(t)
}
}
if t.outbox == nil {
// WithShotOutbox(nil) 是「显式关掉附件」的写法,但工具面必须能解释自己:
// 出一个可用的箱子、由附件状态字段如实报 disabled 由**不带箱**的路径给出,
// 因此这里只兜住 nil(构造期 invariants 一处收紧,动作里就不必到处判空)。
t.outbox = NewShotOutbox()
}
return t
}
@@ -91,6 +101,46 @@ func WithLogger(l *slog.Logger) Option {
return func(t *Tool) { t.log = logger.WithSource(l, logSource) }
}
// WithShotOutbox 换用给定的截图附件箱(B3 闭环的浏览器侧,见 attach.go)。
//
// 传入 nil 当作「不换」:箱子是**必有**的(循环的装配钩子按工具名取回它,见
// `cmd/cyrene/wire_browser_context.go`),把它做成可选只会让「截图到底进不进上下文」
// 取决于装配顺序,而不是取决于设计。
func WithShotOutbox(o *ShotOutbox) Option {
if o == nil {
return func(*Tool) {}
}
return func(t *Tool) { t.outbox = o }
}
// ShotOutbox 返回本工具的附件箱。循环的装配钩子据此取走本回合待送出的截图
// (取走即消费,见 ShotOutbox.Drain)。永不为 nil。
func (t *Tool) ShotOutbox() *ShotOutbox {
if t == nil {
return nil
}
return t.outbox
}
// State 返回面板要的**只读事实快照**(design/28 §8 B4;形状见
// `internal/capability/browser/state.go`)。它**不启动浏览器、不发请求**:
// 面板的只读区块连 viewer 都能看,让「看一眼」产生进程外副作用是越界。
//
// 驱动不支持这一面(假驱动 / 未来的别的实现)时返回零值快照 —— 面板据此如实显示
// 「未知」,而不是编一组数字。
func (t *Tool) State() browsercap.StateSnapshot {
if t == nil || t.mgr == nil {
return browsercap.StateSnapshot{}
}
src, ok := t.mgr.(interface {
State() browsercap.StateSnapshot
})
if !ok {
return browsercap.StateSnapshot{}
}
return src.State()
}
// Name 实现 sdk.Tool。
func (t *Tool) Name() string { return ToolName }
@@ -119,11 +169,12 @@ func (t *Tool) Description() string {
return "Drive a real headful Chromium on this host and bring a page into the conversation. " +
"Actions: " +
"'open' navigates to an http(s) URL and returns the page's own title/url/readyState; " +
"'screenshot' captures the current viewport, saves it to disk (0600) and returns its path — " +
"pass that path to the vision or image-crop tool to actually read the page, which is how a " +
"rendered chart/QR/login page becomes visible to a text-only model; " +
"'screenshot' captures the current viewport, saves it to disk (0600) and attaches it to " +
"THIS turn automatically — a sighted main model receives the image itself, a text-only " +
"model receives a vision/OCR description — so you do not have to re-read it yourself; " +
"'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " +
"for verifying that a headful session really is headful. " +
"For a zoomed region of a large page use the image-crop tool on the returned path. " +
"Rich interaction (click/type/wait/extract/network) is NOT implemented here on purpose: " +
"configure a Playwright or Puppeteer MCP server and use its tools for that."
}
@@ -183,13 +234,19 @@ func (t *Tool) open(ctx context.Context, args map[string]any) (any, error) {
return res, nil
}
// screenshot 处理 action=screenshot:返回落盘路径与统计,「路径交 vision 读」这一步
// 由模型按描述自行发起(本层不替它调模型,也不把图塞进上下文)。
// screenshot 处理 action=screenshot:截图落盘(0600)后**把它挂进本回合的附件箱**
// (B3 闭环:循环的下一次模型调用会把它作为 image 附件送出,见 attach.go),并返回
// 落盘路径与统计。
//
// 为什么要「挂进箱子」而不是像 V5 那样只回路径:只回路径 = 把闭环交给模型自觉
// (它得记得再调一次 vision),那不是闭环。现在两条路都自动:主模型能收图 ⇒ 它看到
// 这张图;不能 ⇒ vision/OCR 工具的文字结果进上下文(design/28 §7 判据 2)。
func (t *Tool) screenshot(ctx context.Context) (any, error) {
shot, err := t.mgr.Screenshot(ctx)
if err != nil {
return nil, err
}
status := t.outbox.PutForContext(ctx, shot)
return map[string]any{
"path": shot.Path,
"bytes": shot.Bytes,
@@ -199,10 +256,26 @@ func (t *Tool) screenshot(ctx context.Context) (any, error) {
"height": shot.Height,
"dimensionsKnown": shot.DimensionsKnown,
"degraded": shot.Degraded,
"hint": "Pass this path to the vision or image-crop tool to read the page content; the image is not inlined into the conversation.",
"turnAttachment": status,
"hint": shotHint(status),
}, nil
}
// shotHint 是按附件状态给出的**如实**下一步提示(进模型上下文,因此措辞必须与事实一致:
// queued 时说「已经作为图片附件送出」,其余档说「图没进上下文,按路径读」)。
func shotHint(status string) string {
switch status {
case TurnAttachmentQueued:
return "This screenshot is queued as an attachment for the current turn and will be " +
"in your context on the next step (as the image itself, or as a vision/OCR description " +
"if your model cannot receive images). Use image-crop on the path above to read a " +
"region at full detail."
default:
return "This screenshot is NOT attached to the conversation (" + status + "). " +
"Pass the path to the vision, OCR or image-crop tool to read the page content."
}
}
// info 处理 action=info。
func (t *Tool) info(ctx context.Context) (any, error) {
fp, err := t.mgr.Fingerprint(ctx)