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

+113 -58
View File
@@ -27,6 +27,9 @@ package main
// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测
// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起
// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。
//
// 构造路径**只有一条**(`buildBrowserTool`):启动期注册(本文件)与面板保存后的
// 热重载(`wire_browser_panel.go`)走的是同一段代码,两处语义不可能漂移。
import (
"context"
@@ -62,6 +65,88 @@ const (
browserNotRegisteredEffect = "the model will not see a browser tool at all"
)
// browserBuild 是「按当前配置把 browser 工具建出来」的结论(成功与失败共用一个形状,
// 使每一条失败路径都只能经**同一个出口**报原因,不会在两条分支里各写各的文案)。
type browserBuild struct {
// tool / mgr 是成功时的产物(失败时两者皆 nil)。
tool *browsertools.Tool
mgr *browsercap.Manager
// reason 失败时非空:分类原因码(`browsercfg.Reason*` 或本文件的 browserReason*)。
reason string
// detail 是给人看的一句话(失败原因细节;不含凭据、不含图片内容)。
detail string
// configPath / configSource 是本次生效配置的来路(进日志,便于对照面板)。
configPath string
configSource string
// source / headless 是探测结论(只进日志)。
source string
headless bool
// available 报告这次是否真的建出了可用的工具。
available bool
}
// buildBrowserTool 走「读文件 → 探测可用性 → profile 守卫 → 建管理器与工具」这四步。
//
// carry 是「要沿用的截图附件箱」:面板保存触发热重载时,重建前的待送截图不能丢
// (模型刚拍完还没送出去的那一张)。为 nil 时工具自带一个新箱子。
func (a *app) buildBrowserTool(carry *browsertools.ShotOutbox) browserBuild {
dataDir := resolveDataDir(a.cfg)
configPath := browsercfg.DefaultConfigPath(dataDir)
fileCfg, err := browsercfg.Load(configPath, dataDir)
if err != nil {
return browserBuild{
reason: browserReasonInvalidConfig,
detail: err.Error(),
configPath: configPath,
}
}
effective := browsercfg.Defaults(dataDir)
configSource := browserConfigSourceProbe
if fileCfg != nil {
effective = *fileCfg
configSource = browserConfigSourceFile
}
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
cancel()
if !avail.Usable {
return browserBuild{
reason: string(avail.Reason),
detail: avail.Detail,
configPath: configPath,
configSource: configSource,
}
}
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
return browserBuild{
reason: browserReasonProfileRejected,
detail: err.Error(),
configPath: configPath,
configSource: configSource,
}
}
mgr := browsercap.NewManager(browsercap.Options{
Availability: avail,
Config: effective,
DataDir: dataDir,
Logger: a.log,
})
opts := []browsertools.Option{browsertools.WithLogger(a.log)}
if carry != nil {
opts = append(opts, browsertools.WithShotOutbox(carry))
}
return browserBuild{
tool: browsertools.New(mgr, opts...),
mgr: mgr,
configPath: configPath,
configSource: configSource,
source: string(avail.Source),
headless: avail.Headless,
available: true,
}
}
// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级**
// (记分类原因 + 不注册),绝不阻塞启动。
func (a *app) wireBrowser() {
@@ -73,66 +158,36 @@ func (a *app) wireBrowser() {
if _, err := a.caps.Get(browsertools.ToolName); err == nil {
return // 同一 app 上重复装配:已经注册过,不重复探测
}
dataDir := resolveDataDir(a.cfg)
configPath := browsercfg.DefaultConfigPath(dataDir)
fileCfg, err := browsercfg.Load(configPath, dataDir)
if err != nil {
log.Warn("browser tool not registered",
"reason", browserReasonInvalidConfig, "configPath", configPath, "error", err,
"effect", browserNotRegisteredEffect)
build, ok := a.registerBrowserBuild(log, nil)
if !ok {
return
}
effective := browsercfg.Defaults(dataDir)
configSource := browserConfigSourceProbe
if fileCfg != nil {
effective = *fileCfg
configSource = browserConfigSourceFile
}
if !a.registerBrowserTool(log, fileCfg, effective, configPath, configSource, dataDir) {
return
}
}
// registerBrowserTool 做「探测 → 建管理器 → 注册」这三步,返回是否真的注册了。
// 抽成独立函数是为了让每一处「不注册」都**只**有一个出口(口径不会在两条分支里漂移)。
func (a *app) registerBrowserTool(
log *slog.Logger, fileCfg *browsercfg.Config, effective browsercfg.Config,
configPath, configSource, dataDir string,
) bool {
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
cancel()
if !avail.Usable {
log.Warn("browser tool not registered",
"reason", string(avail.Reason), "detail", avail.Detail,
"configPath", configPath, "configSource", configSource,
"effect", browserNotRegisteredEffect)
return false
}
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
log.Warn("browser tool not registered",
"reason", browserReasonProfileRejected, "profileDir", effective.ProfileDir,
"error", err, "effect", browserNotRegisteredEffect)
return false
}
mgr := browsercap.NewManager(browsercap.Options{
Availability: avail,
Config: effective,
DataDir: dataDir,
Logger: a.log,
})
tool := browsertools.New(mgr, browsertools.WithLogger(a.log))
if err := a.caps.RegisterTool(tool); err != nil {
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
return false
}
log.Info("browser tool registered",
"source", string(avail.Source), "headless", avail.Headless,
"configSource", configSource, "configPath", configPath,
"shotsDir", browsercfg.DefaultShotsDir(dataDir),
"source", build.source, "headless", build.headless,
"configSource", build.configSource, "configPath", build.configPath,
"shotsDir", browsercfg.DefaultShotsDir(resolveDataDir(a.cfg)),
"richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造")
return true
}
// registerBrowserBuild 建出工具并注册进能力面;任何一步失败都**只**在日志里报分类原因。
// 抽成独立函数是为了让每一处「不注册」都只有一个出口(口径不会在两条分支里漂移)。
func (a *app) registerBrowserBuild(log *slog.Logger, carry *browsertools.ShotOutbox) (browserBuild, bool) {
build := a.buildBrowserTool(carry)
if !build.available {
log.Warn("browser tool not registered",
"reason", build.reason, "detail", build.detail,
"configPath", build.configPath, "configSource", build.configSource,
"effect", browserNotRegisteredEffect)
return browserBuild{}, false
}
if err := a.caps.RegisterTool(build.tool); err != nil {
// 注册失败就把刚建的管理器关掉:它可能已经持有 profile 锁与清扫协程,
// 留着就是一次凭据目录的泄漏(registerBrowserBuild 的失败路径只有这一条)。
if cerr := build.mgr.Close(); cerr != nil {
log.Warn("browser manager close failed after register error", "error", cerr)
}
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
return browserBuild{}, false
}
return build, true
}