Files
AskaEth e0a6bca501 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 已写盯梢用例)
2026-10-06 21:48:50 +08:00

194 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}