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. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。 // // 关于挂点:组合根在启动期只调用 `ioToolOptions()`(wire_runtime.go)这一个归属本片 // 的装配函数,而本片不得改他人拥有的 `wire_runtime.go` / `wire.go` // ⇒ browser 的接线由 `wire_io_tools.go` 的 `ioToolOptions()` 转交给本文件的 // `wireBrowser()`(那一处有同样的说明)。 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" ) // 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 上重复装配:已经注册过,不重复探测 } 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) 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), "richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造") return true }