package main // 本文件是 design/28 §8 **B4(M3 面板入口)的组合根接线**:把 `browser` 插件声明的 // 面板页与六个动作登记到插件管理器上(design/09 §5.2.1 的「插件管理器 = 唯一中枢」)。 // // 五条口径: // // 1. **不开任何路由**:动作走宿主的通用动作端点 // `POST /api/v1/admin/plugins/{id}/actions/{action}`(PLG-2),插件只给动作名; // 2. **面板声明与动作处理器同源**:动作名常量都在 `plugins/tools/browser/panel.go`, // 本文件只做「名字 → 处理器」的映射,不另抄一份名字; // 3. **读面不起浏览器**:`plugin.getBrowserState` / `plugin.listShots` / // `plugin.getFingerprint` 只读内存事实(`state.go` 口径 1)。真正会起浏览器的只有两个 // **写**动作(`plugin.shootPage` / `plugin.checkFingerprint`),它们按 §5.4.1 只对 // owner/admin 可见可调; // 4. **保存即热重载**:`plugin.saveBrowserConfig` 落 `$DATA_DIR/config/plugins/browser.json` // 后**重建**工具并替换注册表条目(`registry.ReplaceTool`),所以面板文案说的 // 「保存后立即生效」是真话(design/09 §5.5 的同一条口径); // 5. **失败要能分类**:一律经 `pluginmgr.ActionError` 交出「错误码 + 文案 + HTTP 状态」, // 绝不回「已保存」而实际没生效(§5.4.6)。 // // ⚠️ **接线状态(如实)**:本文件提供 `wireBrowserPanel(hub, a, log)`,生产接线需要在 // `wire_frontend.go` 的 `wirePanelHub` 里加一行(与 search 的声明并列): // // wireBrowserPanel(hub, a, log) // // 本片(Y4)的文件面**不含** `wire_frontend.go`,因此那一行由主会话落地;落地前本文件 // 只在测试里被使用(`wire_browser_panel_test.go` 用真实 hub + 假浏览器验证「声明过校验、 // 六个动作真能分发」)。 import ( "context" "fmt" "io" "log/slog" "net/http" "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" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) // browserPanelLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。 const browserPanelLogSource = "cmd/cyrene/wire_browser_panel" // 动作失败码(宿主级,只在 browser 面板这组文件里使用)。领域自己的码来自 // `browsercap` 的分类原因码。 const ( // browserCodeConfigUnreadable 报告面板配置存在但读不出来(损坏 / 权限)。 browserCodeConfigUnreadable = "config_unreadable" // browserCodeConfigRejected 报告这次保存的配置**非法**(越界 / 端点非法):没落盘。 browserCodeConfigRejected = "config_rejected" // browserCodeConfigWriteFailed 报告配置写不进去(磁盘 / 权限)。 browserCodeConfigWriteFailed = "config_write_failed" // browserCodeUnavailable 报告本机现在没有可用浏览器来源(工具没进能力面)。 browserCodeUnavailable = "browser_unavailable" // browserCodeNoSession 报告「当前没有打开的页面」:拍一张没有目标(面板不替模型开页)。 browserCodeNoSession = "browser_session_not_open" // browserCodeActionFailed 报告浏览器动作执行失败(分类原因见日志)。 browserCodeActionFailed = "browser_action_failed" ) // wireBrowserPanel 声明 browser 的面板页并把六个动作登记到面板中枢。 // // 声明被拒只告警、不阻断启动(与 search 的处置一致:§5.2.1 的口径是「该面板不注册 + // 显式报错」,不是「宿主陪葬」)。登记失败同理(面板会以 501 如实作答)。 func wireBrowserPanel(hub *pluginmgr.Panels, a *app, log *slog.Logger) { if hub == nil || a == nil { return } env := browserPanelEnv{ app: a, hub: hub, log: browserPanelLogger(log), path: browsercfg.DefaultConfigPath(resolveDataDir(a.cfg)), } env.declare() if err := hub.RegisterActions(browsertools.PanelPluginID, env.handlers()); err != nil { env.log.Error("browser panel actions not registered; panel actions will answer 501", "pluginId", browsertools.PanelPluginID, "error", err) return } env.warnImpliedButMissing() } // browserPanelLogger 给日志补上本文件的来源标签,并兜住 nil(`logger.WithSource(nil, …)` // 返回 nil,而本文件多处直接 `env.log.Info/Warn` —— 对 nil 的 *slog.Logger 调用会 panic)。 // 与 `plugins/tools/browser` 的 discardLog 同一手法:nil 的语义是「不输出」,不是「不标注」。 func browserPanelLogger(base *slog.Logger) *slog.Logger { if base == nil { return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserPanelLogSource) } return logger.WithSource(base, browserPanelLogSource) } // browserPanelEnv 是六个动作共享的接线事实(一次装配、多动作复用)。 // // 刻意只装**在多个动作间共享**的东西(日志出口、配置文件路径、面板中枢),每次调用各自 // 读文件/读注册面:动作之间不共享「当前配置」这份可变状态,就不会出现「A 动作读到的是 // B 动作刚改之前的那份」。 type browserPanelEnv struct { app *app hub *pluginmgr.Panels log *slog.Logger path string } // handlers 返回本插件声明的**六个动作**的处理器(键 = 声明里的动作名,同源引用常量)。 func (e browserPanelEnv) handlers() map[string]pluginmgr.ActionHandler { return map[string]pluginmgr.ActionHandler{ browsertools.ActionState: e.state, browsertools.ActionShots: e.shots, browsertools.ActionFingerprint: e.fingerprint, browsertools.ActionShoot: e.shoot, browsertools.ActionCheck: e.check, browsertools.ActionSaveConfig: e.saveConfig, } } // declare 用**当前盘上的配置**重新声明面板(表单默认值 = 现状)。保存后也会再调一次, // 使面板上的值刷新成刚落盘的那份(前端提交成功后会重取快照)。 func (e browserPanelEnv) declare() { cfg, err := e.currentConfig() if err != nil { e.log.Warn("browser panel declared with default config values", "path", e.path, "error", err) cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg)) } available := e.tool() != nil accepted, derr := e.hub.Declare(browsertools.PanelPluginID, browsertools.AdminPanels(browsertools.PanelInput{ Config: cfg, Available: available, Source: e.source(), })) if derr != nil { e.log.Warn("browser panel declaration rejected", "pluginId", browsertools.PanelPluginID, "accepted", accepted, "error", derr) return } e.log.Info("browser panel declared", "pluginId", browsertools.PanelPluginID, "panels", accepted, "available", available) } // warnImpliedButMissing 把「声明里有、登记里没有」的动作如实报出来(端点对它的结算 // 就是 501)。它是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的运行期版本。 func (e browserPanelEnv) warnImpliedButMissing() { registered := make(map[string]bool) for _, name := range e.hub.ActionHandlerNames(browsertools.PanelPluginID) { registered[name] = true } for _, name := range e.hub.DeclaredActions(browsertools.PanelPluginID) { if !registered[name] { e.log.Warn("declared browser panel action has no handler; it will answer 501", "pluginId", browsertools.PanelPluginID, "action", name) } } } // currentConfig 读盘上的 browser 配置(缺失 ⇒ 默认),已 Normalize。 func (e browserPanelEnv) currentConfig() (browsercfg.Config, error) { dataDir := resolveDataDir(e.app.cfg) fileCfg, err := browsercfg.Load(e.path, dataDir) if err != nil { return browsercfg.Config{}, err } out := browsercfg.Defaults(dataDir) if fileCfg != nil { out = *fileCfg } return out, nil } // tool 从能力面取回 browser 工具(没注册 ⇒ nil:面板要如实说明「这台机器上没有浏览器」, // 而不是报一个空表把「没接线」伪装成「没有数据」)。 func (e browserPanelEnv) tool() *browsertools.Tool { if e.app == nil || e.app.caps == nil { return nil } entry, err := e.app.caps.Get(browsertools.ToolName) if err != nil { return nil } tool, _ := entry.Value.(*browsertools.Tool) return tool } // source 返回来源描述(只进面板描述文案)。 func (e browserPanelEnv) source() string { if tool := e.tool(); tool != nil { return tool.State().Source } return "" } // state 是 `plugin.getBrowserState`:运行状态只读事实面的一行。**不启动浏览器**。 func (e browserPanelEnv) state(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { cfg, err := e.currentConfig() if err != nil { cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg)) } snap := browsercapState(e.tool(), cfg) return map[string]any{"rows": browsertools.StateRows(snap)}, nil } // fingerprint 是 `plugin.getFingerprint`:**上一次**自检的读数(未自检过 ⇒ 如实说「未自检」)。 func (e browserPanelEnv) fingerprint(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { snap := browsercapState(e.tool(), browsercfg.Config{}) return map[string]any{"rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt)}, nil } // shots 是 `plugin.listShots`:最近截图统计(**不含路径与内容**)。 func (e browserPanelEnv) shots(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { snap := browsercapState(e.tool(), browsercfg.Config{}) return map[string]any{"rows": browsertools.ShotRows(snap.Shots)}, nil } // shoot 是 `plugin.shootPage`(写动作,owner-only):拍一张**当前页面**。 // // 为什么需要会话已在跑:面板**不替模型开页**(否则它就成了「任意 URL 的截图器」, // design/28 §1 M3 明确否掉的那个面)。主人先在对话里让它打开某页(或人工在有头窗口里 // 接管),再从面板拍一张 —— 真实排障流程就是这样。 func (e browserPanelEnv) shoot(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) { tool := e.tool() if tool == nil { return nil, pluginmgr.NewActionError(browserCodeUnavailable, "本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)", http.StatusConflict, nil) } if !tool.State().SessionLive { return nil, pluginmgr.NewActionError(browserCodeNoSession, "浏览器当前没有打开的页面:先让她打开一个页面(或在有头窗口里人工接管),再回面板拍一张", http.StatusConflict, nil) } res, err := tool.Execute(ctx, map[string]any{"action": "screenshot"}) if err != nil { e.log.Warn("panel screenshot failed", "error", err) return nil, pluginmgr.NewActionError(browserCodeActionFailed, "截图失败:"+err.Error(), http.StatusBadGateway, err) } return map[string]any{"shot": res}, nil } // check 是 `plugin.checkFingerprint`(写动作,owner-only):真起一次浏览器读指纹。 // 它是本页唯一「更新只读事实面」的写动作 —— 读面本身绝不启动浏览器(口径 1)。 func (e browserPanelEnv) check(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) { tool := e.tool() if tool == nil { return nil, pluginmgr.NewActionError(browserCodeUnavailable, "本机没有可用的浏览器来源,无法自检", http.StatusConflict, nil) } if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil { e.log.Warn("panel fingerprint check failed", "error", err) return nil, pluginmgr.NewActionError(browserCodeActionFailed, "指纹自检失败:"+err.Error(), http.StatusBadGateway, err) } snap := tool.State() return map[string]any{ "rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt), }, nil } // saveConfig 是 `plugin.saveBrowserConfig`(写动作,owner-only):落盘 + 热重载。 // // 事务形状与 web-search 的热重载同族(`websearch_reload.go`): // // ① 快照文件 → ② 校验并落盘 → ③ 按落盘后的配置重建工具并替换注册表条目; // ④ **保存前本来有可用工具**而 ③ 失败 ⇒ 回滚文件 + 409:绝不让一次保存把「盘上的配置」 // 与「跑着的那个浏览器」静默拆开(那正是 design/09 §5.4.6 点名要防的「报成功但没生效」)。 // 保存前本来**没有**可用工具(例如第一次配一个还没起来的容器)⇒ 文件留下、如实回 // `applied=false` + 原因码:那种情形没有能力可丢,报错反而会让主人**没法先配好**。 func (e browserPanelEnv) saveConfig(ctx context.Context, req pluginmgr.ActionRequest) (any, error) { patch, err := browserConfigPatch(req.Payload) if err != nil { return nil, err } cur, err := e.currentConfig() if err != nil { return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable, "面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err) } next, err := applyBrowserConfigPatch(cur, patch) if err != nil { return nil, err } hadTool := e.tool() != nil snap, snapExisted, serr := readConfigSnapshot(e.path) if serr != nil { return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable, "面板配置读不出来,本次**没有**改动它", http.StatusInternalServerError, serr) } if err := browsercfg.Save(e.path, resolveDataDir(e.app.cfg), next); err != nil { return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed, "配置写不进去(磁盘或权限)", http.StatusInternalServerError, err) } applied, registered, reason := e.app.replaceBrowserTool(e.log) if !applied && hadTool { if rerr := restoreConfigSnapshot(e.path, snap, snapExisted); rerr != nil { e.log.Error("browser config rollback failed after a save that did not apply", "path", e.path, "reason", reason, "rollbackError", rerr) return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed, "这份配置没能生效,且回滚失败:盘上的文件可能已经变了(详见服务端日志)", http.StatusInternalServerError, rerr) } return nil, pluginmgr.NewActionError(browserCodeConfigRejected, "这份配置没能生效(已回滚到保存前那一份):"+reason, http.StatusConflict, nil) } e.declare() return map[string]any{ "saved": true, "applied": applied, "registered": registered, "reason": reason, }, nil } // browsercapState 取只读事实快照:没有工具时用**盘上的配置**兜底(面板仍要显示现状), // 而不是回一个空对象让页面显示一片「—」。 func browsercapState(tool *browsertools.Tool, cfg browsercfg.Config) browsercap.StateSnapshot { if tool != nil { return tool.State() } return browsercap.StateSnapshot{ Available: false, Headless: cfg.Headless, ViewportWidth: cfg.ViewportWidth, ViewportHeight: cfg.ViewportHeight, ProfileDir: cfg.ProfileDir, Concurrency: cfg.Concurrency, IdleTimeoutSeconds: cfg.IdleTimeoutSeconds, ScreenshotMaxBytes: cfg.ScreenshotMaxBytes, } } // decodeUserValues 把动作载荷里的 `values` 取出来(前端表单的提交形状是 `{values:{…}}`)。 // 形状不对时返回结构化错误,绝不「当成空补丁」静默保存一份什么都没改的配置。 func decodeUserValues(payload any) (map[string]any, error) { m, ok := payload.(map[string]any) if !ok { return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, "请求体不是对象:表单提交的形状是 {\"values\":{…}}", http.StatusBadRequest, nil) } raw, exists := m["values"] if !exists || raw == nil { return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, "请求体缺少 values(没有要保存的内容)", http.StatusBadRequest, nil) } values, ok := raw.(map[string]any) if !ok { return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, "values 不是对象", http.StatusBadRequest, nil) } return values, nil } // requireString 取一个字符串补丁值(空串是合法值:清空 profile/端点即「回落自动探测」)。 func requireString(key string, raw any) (string, error) { s, ok := raw.(string) if !ok { return "", pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, fmt.Sprintf("字段 %s 需要一个字符串", key), http.StatusBadRequest, nil) } return strings.TrimSpace(s), nil }