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

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