- 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 已写盯梢用例)
367 lines
16 KiB
Go
367 lines
16 KiB
Go
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
|
||
}
|