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 已写盯梢用例)
This commit is contained in:
AskaEth committed 2026-10-06 21:48:50 +08:00
1 parent 4213b268fe
commit e0a6bca501
18 files changed
+3698 -72

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
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)
}
}