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

367 lines
16 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 §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
}