package main // 本文件是 design/28(浏览器与页面截图)B1→B2 的**组合根接线**:把纯库 // `internal/capability/browsercfg`(配置读写 + 可用性探测)接进能力面,并在可用时 // 注册 `browser` 工具(`plugins/tools/browser` 的薄层)。 // // 六条口径: // // 1. **配置只从文件来**:`$DATA_DIR/config/plugins/browser.json`(design/13 §2.1 的 // 插件配置落点、design/28 §1 M2)。**不读任何新增 env 键** —— `.env` 已冻结为 // 「最小化功能可跑」的引导面(AGENTS §10);本文件通篇没有 `os.Getenv`。 // 2. **文件缺失 = 交给自动探测**(`browsercfg.Resolve(nil, …)` 走「本机 // Chrome/Chromium → 已在跑的 CDP 端点」);**文件非法 = 不注册**(如实报错, // 不静默拿默认值顶替 —— 那会让「面板里那份坏配置」永远看不见)。 // 3. **不可用就不注册**(照 web-search `off` 的诚实口径,design/28 §7 判据 1): // 模型根本看不见这个工具,而不是看得见、一调就报错。原因**分类**打进日志 // (`reason=no_source` / `executable_missing` / `cdp_unreachable`),不糊成 // 「失败」两个字。 // 4. **profile 目录含凭据**:注册之前先过 `EnsureProfileDir`(0700 + 不得位于 git // 工作树内,design/28 §2 三件套之 2、§4);不过关就不注册。 // 5. **两条失败都不阻塞启动**(与 wireVision / wireTranscode 同族):浏览器是**可选 // 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。 // 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。 // // 关于挂点(切片 X3 归位):本文件的 `wireBrowser()` 由 `wireRuntime` 调用,位置与 // `wireVision()` / `wireTranscode()` 并列 —— 三者同类(可选增强,不可用就不注册)。 // 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测 // 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起 // 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。 // // 构造路径**只有一条**(`buildBrowserTool`):启动期注册(本文件)与面板保存后的 // 热重载(`wire_browser_panel.go`)走的是同一段代码,两处语义不可能漂移。 import ( "context" "log/slog" "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/logger" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) const ( // browserLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。 browserLogSource = "cmd/cyrene/wire_browser" // browserProbeTimeout 约束可用性探测。探测问的是「有没有在听」(本机可执行文件 // 是否存在、CDP 端点是否回话),不是「页面能不能加载」,所以给得短:最坏情况 // 也只是启动路径上多等 3 秒,而不是拖住整个组合根。 browserProbeTimeout = 3 * time.Second // 生效配置的来源标签(只进日志):一眼看出本次结论是「面板文件说的」还是 // 「没有配置文件、自动探测出来的」。 browserConfigSourceFile = "panel-file" browserConfigSourceProbe = "probe" // 不注册工具时的原因码(进日志的 reason 字段,与 browsercfg.Reason 同风格)。 browserReasonInvalidConfig = "invalid-config" browserReasonProfileRejected = "profile-dir-rejected" // browserNotRegisteredEffect 是「不注册」这件事对模型面**实际**意味着什么: // 写死在日志里,免得读日志的人以为只是「降级运行」。 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() { log := logger.WithSource(a.log, browserLogSource) if a.caps == nil || a.cfg == nil { // 部分测试夹具只填了 app 的几个字段;没有注册面就没有「注册」这件事。 return } if _, err := a.caps.Get(browsertools.ToolName); err == nil { return // 同一 app 上重复装配:已经注册过,不重复探测 } build, ok := a.registerBrowserBuild(log, nil) if !ok { return } log.Info("browser tool registered", "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)提供,本工具不重造") } // 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 }