- 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 已写盯梢用例)
247 lines
9.5 KiB
Go
247 lines
9.5 KiB
Go
package main
|
||
|
||
// 本文件是 browser 面板的**数据面**(切片 Y4 / B4):把动作载荷翻成配置补丁、把配置
|
||
// 写盘/回滚,以及保存之后的**热重载**(重建工具 + 换注册表条目)。
|
||
//
|
||
// 拆出来的理由同 web-search 那一组:`wire_browser_panel.go` 只留「名字 → 处理器」的接线,
|
||
// 载荷解析与文件事务各自成文件,函数才守得住 ≤80 行。
|
||
//
|
||
// 两条口径:
|
||
//
|
||
// 1. **补丁是补丁**:前端提交的是 `{values:{…}}`,而 `planSubmit` 只发**改动过的**字段
|
||
// (见 `frontend/web/src/admin/schema/values.ts`)⇒ 没出现的键一律**保持原值**,
|
||
// 绝不把「没提到」当成「清空」(那是静默清空的经典事故,design/09 §5.4.2);
|
||
// 2. **回滚是凭据**:保存前先把盘上那一份读进内存,热重载失败就写回去 —— 绝不把后端
|
||
// 留在「盘上这份配置让它下次起不来」的状态。
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
|
||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||
)
|
||
|
||
// browserConfigRollbackMode 是回滚写回时的文件权限:与 `browsercfg.Save` 的 0600 同口径
|
||
// (那份文件带显式 CDP 端点与 profile 位置)。硬编码 0600 而不是引用对方的常量,是因为
|
||
// 变化方向是「收紧」,不存在比 0600 更松的风险。
|
||
const browserConfigRollbackMode os.FileMode = 0o600
|
||
|
||
// browserConfigPatch 解析表单提交的载荷(`{values:{…}}`)并做**形状**校验:
|
||
// 只接受本面板声明过的键,未知键一律拒(**绝不静默忽略** —— 静默忽略会让「我改了但它没生效」
|
||
// 变成查不出的谜,design/09 §5.2.1 的同一条口径)。
|
||
func browserConfigPatch(payload any) (map[string]any, error) {
|
||
values, err := decodeUserValues(payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
known := map[string]bool{
|
||
browsertools.FieldHeadless: true,
|
||
browsertools.FieldExecutable: true,
|
||
browsertools.FieldCDPEndpoint: true,
|
||
browsertools.FieldProfileDir: true,
|
||
browsertools.FieldViewportWidth: true,
|
||
browsertools.FieldViewportHeight: true,
|
||
browsertools.FieldIdleTimeout: true,
|
||
browsertools.FieldConcurrency: true,
|
||
}
|
||
out := make(map[string]any, len(values))
|
||
for key, raw := range values {
|
||
if !known[key] {
|
||
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||
fmt.Sprintf("未知字段 %q:本面板只接受 %d 个已声明字段", key, len(known)),
|
||
http.StatusBadRequest, nil)
|
||
}
|
||
out[key] = raw
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// applyBrowserConfigPatch 把补丁合进当前配置。未出现的键保持原值(见文件头口径 1)。
|
||
//
|
||
// 类型不匹配一律报 400(`values` 已由前端按声明类型还原:number → 数字、switch → 布尔);
|
||
// 越界值交给 `browsercfg.Validate` 判(它只判**原始值**,不 Normalize —— 悄悄改小会让
|
||
// 面板显示的与生效的不是一回事)。
|
||
func applyBrowserConfigPatch(cur browsercfg.Config, patch map[string]any) (browsercfg.Config, error) {
|
||
out := cur
|
||
for key, raw := range patch {
|
||
var err error
|
||
switch key {
|
||
case browsertools.FieldHeadless:
|
||
out.Headless, err = patchBool(key, raw)
|
||
case browsertools.FieldExecutable:
|
||
out.Executable, err = requireString(key, raw)
|
||
case browsertools.FieldCDPEndpoint:
|
||
out.CDPEndpoint, err = requireString(key, raw)
|
||
case browsertools.FieldProfileDir:
|
||
out.ProfileDir, err = requireString(key, raw)
|
||
case browsertools.FieldViewportWidth:
|
||
out.ViewportWidth, err = patchInt(key, raw)
|
||
case browsertools.FieldViewportHeight:
|
||
out.ViewportHeight, err = patchInt(key, raw)
|
||
case browsertools.FieldIdleTimeout:
|
||
out.IdleTimeoutSeconds, err = patchInt(key, raw)
|
||
case browsertools.FieldConcurrency:
|
||
out.Concurrency, err = patchInt(key, raw)
|
||
default:
|
||
err = fmt.Errorf("未知字段 %q", key)
|
||
}
|
||
if err != nil {
|
||
return browsercfg.Config{}, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
|
||
fmt.Sprintf("字段 %s 的取值不合法:%v", key, err), http.StatusBadRequest, err)
|
||
}
|
||
}
|
||
if verr := out.Validate(); verr != nil {
|
||
return browsercfg.Config{}, pluginmgr.NewActionError(browserCodeConfigRejected,
|
||
"这份配置超出硬上限,**没有**落盘:"+verr.Error(), http.StatusConflict, verr)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// patchBool 取一个布尔补丁值。
|
||
func patchBool(key string, raw any) (bool, error) {
|
||
v, ok := raw.(bool)
|
||
if !ok {
|
||
return false, fmt.Errorf("需要布尔值")
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// patchInt 取一个数值补丁值(JSON 数字解出来是 float64;整数字符串也认,因为
|
||
// 「留空/清空」在某些控件上会以空串抵达)。
|
||
func patchInt(key string, raw any) (int, error) {
|
||
switch v := raw.(type) {
|
||
case float64:
|
||
if v != float64(int(v)) {
|
||
return 0, fmt.Errorf("需要整数")
|
||
}
|
||
return int(v), nil
|
||
case int:
|
||
return v, nil
|
||
case string:
|
||
trimmed := strings.TrimSpace(v)
|
||
if trimmed == "" {
|
||
// 清空 = 回落默认(Normalize 的口径:≤0 ⇒ 用默认值)。
|
||
return 0, nil
|
||
}
|
||
var n int
|
||
if _, err := fmt.Sscanf(trimmed, "%d", &n); err != nil {
|
||
return 0, fmt.Errorf("需要整数")
|
||
}
|
||
return n, nil
|
||
default:
|
||
return 0, fmt.Errorf("需要整数")
|
||
}
|
||
}
|
||
|
||
// readConfigSnapshot 读一份文件快照(回滚凭据)。文件不存在时返回 (nil, false, nil) ——
|
||
// 那是「保存前没有配置文件」这种合法状态,回滚时要把它恢复成「没有文件」。
|
||
func readConfigSnapshot(path string) ([]byte, bool, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, false, nil
|
||
}
|
||
return nil, false, fmt.Errorf("读配置快照 %s: %w", path, err)
|
||
}
|
||
return raw, true, nil
|
||
}
|
||
|
||
// restoreConfigSnapshot 把文件恢复到快照那一份;快照本来不存在 ⇒ 删掉它。
|
||
func restoreConfigSnapshot(path string, raw []byte, existed bool) error {
|
||
if !existed {
|
||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||
return fmt.Errorf("删除配置 %s: %w", path, err)
|
||
}
|
||
return nil
|
||
}
|
||
if err := os.WriteFile(path, raw, browserConfigRollbackMode); err != nil {
|
||
return fmt.Errorf("回写配置 %s: %w", path, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// replaceBrowserTool 按**落盘后的配置**重建 browser 工具并替换能力面里的条目
|
||
// (design/09 §5.5 的口径:面板保存后下一轮即用新接线,不必重启)。
|
||
//
|
||
// 返回 (applied, registered, reason):
|
||
// - applied=true 报告工具真的被换掉/加上(reason 为空);
|
||
// - applied=false 时 reason 是分类原因码(`browsercfg.Reason*` 或本文件的
|
||
// "replace-failed"/"register-failed"/"no-registry"),调用方据此决定回滚与文案。
|
||
//
|
||
// 两种「不生效」都**不改动既有的工具**:没有可用来源时工具不该存在(判据 1 的诚实口径),
|
||
// 但也没理由因为一次保存把已经在跑的那一把弄没(那是「保存一次就少一个能力」的事故形状)。
|
||
func (a *app) replaceBrowserTool(log *slog.Logger) (bool, bool, string) {
|
||
if a == nil || a.caps == nil {
|
||
return false, false, "no-registry"
|
||
}
|
||
existing, gerr := a.caps.Get(browsertools.ToolName)
|
||
haveExisting := gerr == nil
|
||
carry := shotOutboxOf(existing.Value)
|
||
|
||
build := a.buildBrowserTool(carry)
|
||
if !build.available {
|
||
// 新配置下没有可用来源:工具**不注册**(design/28 §7 判据 1 的诚实口径)。
|
||
log.Warn("browser tool not rebuilt after config save",
|
||
"reason", build.reason, "detail", build.detail,
|
||
"configPath", build.configPath, "effect", browserNotRegisteredEffect)
|
||
return false, haveExisting, build.reason
|
||
}
|
||
if haveExisting {
|
||
if err := a.caps.ReplaceTool(build.tool); err != nil {
|
||
closeBrowserManager(log, build.mgr)
|
||
log.Warn("browser tool replace failed", "error", err)
|
||
return false, true, "replace-failed"
|
||
}
|
||
closeOldBrowserTool(log, existing.Value)
|
||
return true, true, ""
|
||
}
|
||
if err := a.caps.RegisterTool(build.tool); err != nil {
|
||
closeBrowserManager(log, build.mgr)
|
||
log.Warn("browser tool register failed after config save", "error", err)
|
||
return false, false, "register-failed"
|
||
}
|
||
log.Info("browser tool registered after config save",
|
||
"source", build.source, "headless", build.headless, "configSource", build.configSource)
|
||
return true, true, ""
|
||
}
|
||
|
||
// shotOutboxOf 取旧条目上的附件箱(重建时沿用:模型刚拍完还没送出去的那一张不能丢)。
|
||
// 用结构断言而不是具体类型:换工具实现只要还暴露 ShotOutbox 就接得上。
|
||
func shotOutboxOf(value any) *browsertools.ShotOutbox {
|
||
holder, ok := value.(interface {
|
||
ShotOutbox() *browsertools.ShotOutbox
|
||
})
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return holder.ShotOutbox()
|
||
}
|
||
|
||
// closeOldBrowserTool 关掉被换下来的工具(它持有进程外资源:我们起的真 Chromium、CDP 连接、
|
||
// profile 锁)。不关就是每次保存泄漏一个浏览器进程。
|
||
func closeOldBrowserTool(log *slog.Logger, value any) {
|
||
closer, ok := value.(interface{ Close() error })
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := closer.Close(); err != nil {
|
||
log.Warn("old browser tool close failed", "error", err)
|
||
}
|
||
}
|
||
|
||
// closeBrowserManager 关掉刚建好但没能接上的管理器(注册被拒 / 替换失败时的收尾)。
|
||
func closeBrowserManager(log *slog.Logger, mgr *browsercap.Manager) {
|
||
if mgr == nil {
|
||
return
|
||
}
|
||
if err := mgr.Close(); err != nil {
|
||
log.Warn("browser manager close failed", "error", err)
|
||
}
|
||
}
|