- 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 已写盯梢用例)
194 lines
9.0 KiB
Go
194 lines
9.0 KiB
Go
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
|
||
}
|