Files
Cyrene_v2/cmd/cyrene/wire_browser_panel_data.go
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

247 lines
9.5 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
// 本文件是 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)
}
}