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:
1 parent
4213b268fe
commit
e0a6bca501
18 files changed
+3698
-72
No files matched your search
+113
-58
@@ -27,6 +27,9 @@ package main
|
||||
// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测
|
||||
// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起
|
||||
// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。
|
||||
//
|
||||
// 构造路径**只有一条**(`buildBrowserTool`):启动期注册(本文件)与面板保存后的
|
||||
// 热重载(`wire_browser_panel.go`)走的是同一段代码,两处语义不可能漂移。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -62,6 +65,88 @@ const (
|
||||
browserNotRegisteredEffect = "the model will not see a browser tool at all"
|
||||
)
|
||||
|
||||
// browserBuild 是「按当前配置把 browser 工具建出来」的结论(成功与失败共用一个形状,
|
||||
// 使每一条失败路径都只能经**同一个出口**报原因,不会在两条分支里各写各的文案)。
|
||||
type browserBuild struct {
|
||||
// tool / mgr 是成功时的产物(失败时两者皆 nil)。
|
||||
tool *browsertools.Tool
|
||||
mgr *browsercap.Manager
|
||||
// reason 失败时非空:分类原因码(`browsercfg.Reason*` 或本文件的 browserReason*)。
|
||||
reason string
|
||||
// detail 是给人看的一句话(失败原因细节;不含凭据、不含图片内容)。
|
||||
detail string
|
||||
// configPath / configSource 是本次生效配置的来路(进日志,便于对照面板)。
|
||||
configPath string
|
||||
configSource string
|
||||
// source / headless 是探测结论(只进日志)。
|
||||
source string
|
||||
headless bool
|
||||
// available 报告这次是否真的建出了可用的工具。
|
||||
available bool
|
||||
}
|
||||
|
||||
// buildBrowserTool 走「读文件 → 探测可用性 → profile 守卫 → 建管理器与工具」这四步。
|
||||
//
|
||||
// carry 是「要沿用的截图附件箱」:面板保存触发热重载时,重建前的待送截图不能丢
|
||||
// (模型刚拍完还没送出去的那一张)。为 nil 时工具自带一个新箱子。
|
||||
func (a *app) buildBrowserTool(carry *browsertools.ShotOutbox) browserBuild {
|
||||
dataDir := resolveDataDir(a.cfg)
|
||||
configPath := browsercfg.DefaultConfigPath(dataDir)
|
||||
fileCfg, err := browsercfg.Load(configPath, dataDir)
|
||||
if err != nil {
|
||||
return browserBuild{
|
||||
reason: browserReasonInvalidConfig,
|
||||
detail: err.Error(),
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
effective := browsercfg.Defaults(dataDir)
|
||||
configSource := browserConfigSourceProbe
|
||||
if fileCfg != nil {
|
||||
effective = *fileCfg
|
||||
configSource = browserConfigSourceFile
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
|
||||
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
|
||||
cancel()
|
||||
if !avail.Usable {
|
||||
return browserBuild{
|
||||
reason: string(avail.Reason),
|
||||
detail: avail.Detail,
|
||||
configPath: configPath,
|
||||
configSource: configSource,
|
||||
}
|
||||
}
|
||||
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
|
||||
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
|
||||
return browserBuild{
|
||||
reason: browserReasonProfileRejected,
|
||||
detail: err.Error(),
|
||||
configPath: configPath,
|
||||
configSource: configSource,
|
||||
}
|
||||
}
|
||||
mgr := browsercap.NewManager(browsercap.Options{
|
||||
Availability: avail,
|
||||
Config: effective,
|
||||
DataDir: dataDir,
|
||||
Logger: a.log,
|
||||
})
|
||||
opts := []browsertools.Option{browsertools.WithLogger(a.log)}
|
||||
if carry != nil {
|
||||
opts = append(opts, browsertools.WithShotOutbox(carry))
|
||||
}
|
||||
return browserBuild{
|
||||
tool: browsertools.New(mgr, opts...),
|
||||
mgr: mgr,
|
||||
configPath: configPath,
|
||||
configSource: configSource,
|
||||
source: string(avail.Source),
|
||||
headless: avail.Headless,
|
||||
available: true,
|
||||
}
|
||||
}
|
||||
|
||||
// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级**
|
||||
// (记分类原因 + 不注册),绝不阻塞启动。
|
||||
func (a *app) wireBrowser() {
|
||||
@@ -73,66 +158,36 @@ func (a *app) wireBrowser() {
|
||||
if _, err := a.caps.Get(browsertools.ToolName); err == nil {
|
||||
return // 同一 app 上重复装配:已经注册过,不重复探测
|
||||
}
|
||||
|
||||
dataDir := resolveDataDir(a.cfg)
|
||||
configPath := browsercfg.DefaultConfigPath(dataDir)
|
||||
fileCfg, err := browsercfg.Load(configPath, dataDir)
|
||||
if err != nil {
|
||||
log.Warn("browser tool not registered",
|
||||
"reason", browserReasonInvalidConfig, "configPath", configPath, "error", err,
|
||||
"effect", browserNotRegisteredEffect)
|
||||
build, ok := a.registerBrowserBuild(log, nil)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
effective := browsercfg.Defaults(dataDir)
|
||||
configSource := browserConfigSourceProbe
|
||||
if fileCfg != nil {
|
||||
effective = *fileCfg
|
||||
configSource = browserConfigSourceFile
|
||||
}
|
||||
if !a.registerBrowserTool(log, fileCfg, effective, configPath, configSource, dataDir) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// registerBrowserTool 做「探测 → 建管理器 → 注册」这三步,返回是否真的注册了。
|
||||
// 抽成独立函数是为了让每一处「不注册」都**只**有一个出口(口径不会在两条分支里漂移)。
|
||||
func (a *app) registerBrowserTool(
|
||||
log *slog.Logger, fileCfg *browsercfg.Config, effective browsercfg.Config,
|
||||
configPath, configSource, dataDir string,
|
||||
) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
|
||||
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
|
||||
cancel()
|
||||
if !avail.Usable {
|
||||
log.Warn("browser tool not registered",
|
||||
"reason", string(avail.Reason), "detail", avail.Detail,
|
||||
"configPath", configPath, "configSource", configSource,
|
||||
"effect", browserNotRegisteredEffect)
|
||||
return false
|
||||
}
|
||||
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
|
||||
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
|
||||
log.Warn("browser tool not registered",
|
||||
"reason", browserReasonProfileRejected, "profileDir", effective.ProfileDir,
|
||||
"error", err, "effect", browserNotRegisteredEffect)
|
||||
return false
|
||||
}
|
||||
|
||||
mgr := browsercap.NewManager(browsercap.Options{
|
||||
Availability: avail,
|
||||
Config: effective,
|
||||
DataDir: dataDir,
|
||||
Logger: a.log,
|
||||
})
|
||||
tool := browsertools.New(mgr, browsertools.WithLogger(a.log))
|
||||
if err := a.caps.RegisterTool(tool); err != nil {
|
||||
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
|
||||
return false
|
||||
}
|
||||
log.Info("browser tool registered",
|
||||
"source", string(avail.Source), "headless", avail.Headless,
|
||||
"configSource", configSource, "configPath", configPath,
|
||||
"shotsDir", browsercfg.DefaultShotsDir(dataDir),
|
||||
"source", build.source, "headless", build.headless,
|
||||
"configSource", build.configSource, "configPath", build.configPath,
|
||||
"shotsDir", browsercfg.DefaultShotsDir(resolveDataDir(a.cfg)),
|
||||
"richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造")
|
||||
return true
|
||||
}
|
||||
|
||||
// registerBrowserBuild 建出工具并注册进能力面;任何一步失败都**只**在日志里报分类原因。
|
||||
// 抽成独立函数是为了让每一处「不注册」都只有一个出口(口径不会在两条分支里漂移)。
|
||||
func (a *app) registerBrowserBuild(log *slog.Logger, carry *browsertools.ShotOutbox) (browserBuild, bool) {
|
||||
build := a.buildBrowserTool(carry)
|
||||
if !build.available {
|
||||
log.Warn("browser tool not registered",
|
||||
"reason", build.reason, "detail", build.detail,
|
||||
"configPath", build.configPath, "configSource", build.configSource,
|
||||
"effect", browserNotRegisteredEffect)
|
||||
return browserBuild{}, false
|
||||
}
|
||||
if err := a.caps.RegisterTool(build.tool); err != nil {
|
||||
// 注册失败就把刚建的管理器关掉:它可能已经持有 profile 锁与清扫协程,
|
||||
// 留着就是一次凭据目录的泄漏(registerBrowserBuild 的失败路径只有这一条)。
|
||||
if cerr := build.mgr.Close(); cerr != nil {
|
||||
log.Warn("browser manager close failed after register error", "error", cerr)
|
||||
}
|
||||
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
|
||||
return browserBuild{}, false
|
||||
}
|
||||
return build, true
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §7 判据 2(M4 闭环)的**组合根落点**:把「截图 → 进本回合上下文」
|
||||
// 接成一条自动的路。三件事各归各家,本文件只做中间那一段接线:
|
||||
//
|
||||
// plugins/tools/browser(交图) → 本文件(装配期并入本轮视图) → multimodal/vision(成图/成文)
|
||||
//
|
||||
// 具体地:
|
||||
//
|
||||
// 1. `browser` 工具的 `screenshot` 动作落盘后把这张图放进它**自己的附件箱**
|
||||
// (`plugins/tools/browser/attach.go`,按 `loop.SessionFromContext` 的会话分桶);
|
||||
// 2. 本文件给出的 `TransformContext` 钩子在**每个模型步**之前被循环调用
|
||||
// (`internal/runtime/loop/memory.go` 的 assembleContext 末尾),把该会话的待送截图
|
||||
// **取走即消费**,经 `vision.ShotAttacher` 变成一条带图(或带文字描述)的 user 消息,
|
||||
// 追加进本轮视图;
|
||||
// 3. `ShotAttacher` 用**既有**的视觉路由决定形态:主模型能收图 ⇒ 内联成真的 image 部件;
|
||||
// 收不了图 ⇒ vision/OCR 工具产出的文字(design/28 §5 的那一行)。
|
||||
//
|
||||
// 为什么用 `TransformContext` 而不是自己往 msgs 里写:循环对这个钩子的契约就是
|
||||
// 「投影、过滤或重排模型所见的消息」(`loop.Options.TransformContext`),而且它
|
||||
// **只影响本轮视图**、不写回运行窗口 —— 截图因此不会进 checkpoint 快照,也不会进
|
||||
// 后台记忆提取的输入面(design/28 §4 第 3 条:「默认不进长期记忆」,截图不是记忆)。
|
||||
//
|
||||
// ⚠️ **接线状态(如实)**:本文件提供 `a.browserShotTransform(...)`,生产接线需要
|
||||
// `wire_runtime.go` 的 `loop.New(loop.Options{…})` 里加一行:
|
||||
//
|
||||
// TransformContext: a.browserShotTransform(visionResolver),
|
||||
//
|
||||
// 本片(Y4)的文件面**不含** `wire_runtime.go`,因此那一行由主会话落地;落地前本文件
|
||||
// 只在测试里被使用(`wire_browser_context_test.go` 用真实 loop + 探针适配器验证
|
||||
// 「装上它之后,模型请求里确实出现 image 部件」)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// browserShotLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
|
||||
const browserShotLogSource = "cmd/cyrene/wire_browser_context"
|
||||
|
||||
// cropToolName 是裁切能力的工具名(`plugins/tools/io/crop.go` 的 Name)。
|
||||
// 「大图先过 crop」这条要求落在这里:视觉层按区域裁一块再内联,不自己实现裁剪。
|
||||
const cropToolName = "image-crop"
|
||||
|
||||
// browserShotTransform 造出装给 `loop.Options.TransformContext` 的钩子。
|
||||
//
|
||||
// resolver 就是运行时那一个 `*vision.MessageResolver`(组合根已经建好的那个实例),
|
||||
// 因此截图与用户附图共用同一个 Router、同一份 data: URI 缓存 —— 两处各建一个会让
|
||||
// 「同一种图两种处理」变成可能。
|
||||
func (a *app) browserShotTransform(resolver *vision.MessageResolver) func(context.Context, []model.Message, int) []model.Message {
|
||||
injector := &browserShotInjector{
|
||||
app: a,
|
||||
log: browserShotLogger(a.log),
|
||||
}
|
||||
if resolver != nil {
|
||||
injector.attacher = vision.NewShotAttacher(resolver,
|
||||
vision.WithShotCropper(browserShotCropper{invoke: a.browserInvoker()}))
|
||||
}
|
||||
return injector.apply
|
||||
}
|
||||
|
||||
// browserShotLogger 给日志补来源标签并兜住 nil:`a.log` 为 nil 的夹具下
|
||||
// `logger.WithSource` 会返回 nil,而注入器会直接调用它(nil 的 *slog.Logger 会 panic)。
|
||||
func browserShotLogger(base *slog.Logger) *slog.Logger {
|
||||
if base == nil {
|
||||
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserShotLogSource)
|
||||
}
|
||||
return logger.WithSource(base, browserShotLogSource)
|
||||
}
|
||||
|
||||
// browserShotInjector 是钩子的实现体(把状态与函数分开,便于单测直接构造)。
|
||||
type browserShotInjector struct {
|
||||
app *app
|
||||
attacher *vision.ShotAttacher
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// apply 在每个模型步之前把待送截图并入本轮视图。
|
||||
//
|
||||
// 它**绝不**因为一张图的问题让整个回合失败:任何一步失败都如实降级(图读不出来就只发
|
||||
// 一句「没送成」的说明,模型仍能按路径自己读),因为「截图送不进去」远比「回合直接报错」
|
||||
// 轻(同 vision 的既有口径:图像解析失败保留原消息、回合继续)。
|
||||
func (in *browserShotInjector) apply(ctx context.Context, msgs []model.Message, _ int) []model.Message {
|
||||
outbox := in.outbox()
|
||||
if outbox == nil || in.attacher == nil {
|
||||
return msgs
|
||||
}
|
||||
shots := outbox.Drain(loop.SessionFromContext(ctx))
|
||||
if len(shots) == 0 {
|
||||
return msgs
|
||||
}
|
||||
out := msgs
|
||||
for _, shot := range shots {
|
||||
att, err := in.attacher.Attach(ctx, vision.ShotRef{
|
||||
Path: shot.Path,
|
||||
Bytes: shot.Bytes,
|
||||
Width: shot.Width,
|
||||
Height: shot.Height,
|
||||
Format: shot.Format,
|
||||
})
|
||||
if err != nil {
|
||||
// 如实说明「这张图没送成」,并给出路径:模型仍可用 vision / image-crop 自己读。
|
||||
in.log.Warn("browser screenshot not attached to the turn",
|
||||
"path", shot.Path, "bytes", shot.Bytes, "error", err)
|
||||
out = append(out, attachmentFailureMessage(shot.Path, err))
|
||||
continue
|
||||
}
|
||||
in.log.Info("browser screenshot attached to the turn",
|
||||
"path", shot.Path, "bytes", shot.Bytes, "route", att.Route, "cropped", att.Cropped)
|
||||
out = append(out, attachmentMessage(att))
|
||||
}
|
||||
// 触发一次 len 变化后返回新切片(不修改入参切片:钩子的契约是「可以投影」,
|
||||
// 而原地追加会改到循环持有的 msgs 底层数组)。
|
||||
return append([]model.Message(nil), out...)
|
||||
}
|
||||
|
||||
// outbox 取回 browser 工具的附件箱。工具没注册(本机没有浏览器)时返回 nil。
|
||||
//
|
||||
// 用**结构断言**而不是具体类型:将来若给工具套一层装饰(例如审计包装),
|
||||
// 只要它还暴露 ShotOutbox 就仍然接得上,不必改这里。
|
||||
func (in *browserShotInjector) outbox() *browsertools.ShotOutbox {
|
||||
if in.app == nil || in.app.caps == nil {
|
||||
return nil
|
||||
}
|
||||
entry, err := in.app.caps.Get(browsertools.ToolName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
holder, ok := entry.Value.(interface {
|
||||
ShotOutbox() *browsertools.ShotOutbox
|
||||
})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return holder.ShotOutbox()
|
||||
}
|
||||
|
||||
// browserInvoker 返回工具调用器(`image-crop` 经它调)。dispatcher 未接时返回 nil,
|
||||
// 视觉层据此判断「没有裁切能力」并如实降级(见 vision.ShotAttacher.cropped)。
|
||||
func (a *app) browserInvoker() vision.ToolInvoker {
|
||||
if a == nil || a.dispatcher == nil {
|
||||
return nil
|
||||
}
|
||||
return a.dispatcher.Invoke
|
||||
}
|
||||
|
||||
// attachmentMessage 把一条附件结论渲染成**一条 user 消息**。
|
||||
//
|
||||
// 为什么是 user 角色:DeepSeek / Anthropic 协议的图像部件**只允许出现在 user 消息**里
|
||||
// (design/24 §2.3 第 3 条),而工具结果回合在本项目是 `RoleTool`(文本-only 形状,
|
||||
// `loop.toolResultMessage`)—— 往那里塞 image 部件会被编码器静默丢掉。
|
||||
func attachmentMessage(att vision.ShotAttachment) model.Message {
|
||||
note := strings.TrimSpace(att.Note)
|
||||
if note == "" {
|
||||
note = "[截图附件]这一步在浏览器里拍下的一页。"
|
||||
}
|
||||
parts := make([]model.Content, 0, 2)
|
||||
parts = append(parts, model.Content{Type: model.ContentTypeText, Text: note})
|
||||
if part := att.Content; part.Type == model.ContentTypeImage || strings.TrimSpace(part.Text) != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return model.Message{Role: model.RoleUser, Content: parts}
|
||||
}
|
||||
|
||||
// attachmentFailureMessage 是「这张图没送成」的如实说明(**不带**图片内容,
|
||||
// 只带路径与病因分类),使模型不会以为页面里什么都没有。
|
||||
func attachmentFailureMessage(path string, err error) model.Message {
|
||||
text := fmt.Sprintf("[截图附件]刚才那张截图**没有**并入本轮上下文(%v)。完整图在 %s,"+
|
||||
"可用 vision、OCR 或 image-crop 工具按路径自行读取。", err, path)
|
||||
return model.Message{Role: model.RoleUser, Content: []model.Content{
|
||||
{Type: model.ContentTypeText, Text: text},
|
||||
}}
|
||||
}
|
||||
|
||||
// browserShotCropper 是「大图先过 crop」的执行者:把区域裁切交给既有的 `image-crop`
|
||||
// 工具(同一份实现、同一份上限与同一套坐标语义),本层不重写裁剪。
|
||||
type browserShotCropper struct {
|
||||
invoke vision.ToolInvoker
|
||||
}
|
||||
|
||||
// CropRegion 实现 vision.ShotCropper。
|
||||
func (c browserShotCropper) CropRegion(ctx context.Context, path string, x, y, w, h int) (string, error) {
|
||||
if c.invoke == nil {
|
||||
return "", fmt.Errorf("browser: 没有可用的裁切能力(工具分发器未接线)")
|
||||
}
|
||||
res, err := c.invoke(ctx, cropToolName, map[string]any{
|
||||
"image": path,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": w,
|
||||
"height": h,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("browser: %s: %w", cropToolName, err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("browser: %s 返回了非对象结果(%T)", cropToolName, res)
|
||||
}
|
||||
uri, _ := m["image"].(string)
|
||||
if strings.TrimSpace(uri) == "" {
|
||||
return "", fmt.Errorf("browser: %s 没返回裁切结果", cropToolName)
|
||||
}
|
||||
return uri, nil
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §7 **判据 2(M4 闭环)的真跑**(切片 Y4 / B3):
|
||||
//
|
||||
// 一张**图里才有、HTML 里没有**的内容(这里由一张真实 PNG 代表)⇒ 它进了模型请求。
|
||||
//
|
||||
// 走的是**真实循环**(`loop.New` + 探针适配器,与组合根同一个装配形状),因此它验的是
|
||||
// 完整那一段:截图箱 → `TransformContext` 钩子 → 视觉路由(主模型能收图 ⇒ 真 image 部件;
|
||||
// 收不了图 ⇒ vision 工具产出的文字)→ 模型请求。
|
||||
//
|
||||
// 三段各自归各家、这里只做端到端:
|
||||
// - 截图**落盘**(真 Chrome、真 CDP)由 `wire_browser_e2e_test.go` 与
|
||||
// `internal/capability/browser/compare_test.go` 覆盖(需要真机,默认 Skip);
|
||||
// - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖;
|
||||
// - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。
|
||||
//
|
||||
// ⚠️ **接线状态(如实)**:本文件把 `a.browserShotTransform(...)` 装进 `loop.Options`
|
||||
// 来验它;生产是否装上由 `wire_runtime.go` 的一行决定(本片无权改那个文件,见
|
||||
// `wire_browser_context.go` 的接线状态注释)。`TestWireRuntime_WiresBrowserShotTransform`
|
||||
// 会盯着那一行:装上之前它 Skip 并写明要加什么,装上之后它就是正向断言。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// b3Session 是本文件用的会话 id(截图箱按会话分桶,测试与循环必须用同一个)。
|
||||
const b3Session = "sess-b3"
|
||||
|
||||
// b3RenderOnlyText 是「图里才有、HTML 里没有」的那句话:走工具形态时它必须出现在
|
||||
// 模型请求里(纯文本路径的正面证据)。
|
||||
const b3RenderOnlyText = "计费周期:2026-09 至 2026-10(这是渲染后才出现的文字)"
|
||||
|
||||
// b3PNG 造一张真实 PNG(视觉层对字节**嗅探**,假字节会被拒)。
|
||||
func b3PNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 6, 4))
|
||||
img.Set(0, 0, color.RGBA{R: 0x10, G: 0x20, B: 0x30, A: 0xff})
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("编码 PNG: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// b3Fixture 是一次闭环验证的夹具:真 loop + 探针适配器 + 装着截图的 browser 工具。
|
||||
type b3Fixture struct {
|
||||
adapter *compactProbeAdapter
|
||||
loop *loop.Loop
|
||||
outbox *browsertools.ShotOutbox
|
||||
shot string
|
||||
}
|
||||
|
||||
// newB3Fixture 装配夹具。sighted 决定主模型能不能收图(两条闭环路径的分岔点就在这里)。
|
||||
func newB3Fixture(t *testing.T, sighted bool) *b3Fixture {
|
||||
t.Helper()
|
||||
data := b3PNG(t)
|
||||
shotPath := filepath.Join(t.TempDir(), "shot-b3.png")
|
||||
if err := os.WriteFile(shotPath, data, 0o600); err != nil {
|
||||
t.Fatalf("写截图: %v", err)
|
||||
}
|
||||
|
||||
outbox := browsertools.NewShotOutbox()
|
||||
outbox.Put(b3Session, browsercapShot(shotPath, len(data)))
|
||||
|
||||
// 只接**附件箱**这一面:本用例验的是「截图落盘之后」那一段,真浏览器由真机 e2e 覆盖。
|
||||
tool := browsertools.New(nil, browsertools.WithShotOutbox(outbox))
|
||||
var out bytes.Buffer
|
||||
a := &app{
|
||||
cfg: &config.Config{DataDir: t.TempDir()},
|
||||
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
|
||||
caps: registry.New(),
|
||||
}
|
||||
if err := a.caps.RegisterTool(tool); err != nil {
|
||||
t.Fatalf("注册 browser 工具: %v", err)
|
||||
}
|
||||
|
||||
adapter := &compactProbeAdapter{}
|
||||
resolver := vision.NewMessageResolver(
|
||||
vision.NewRouter(vision.WithVisionTool("vision"), vision.WithOCRTool("ocr")),
|
||||
func() vision.ModelInfo {
|
||||
return vision.ModelInfo{Name: "main", SupportsVision: sighted}
|
||||
},
|
||||
func(_ context.Context, _ string, _ map[string]any) (any, error) {
|
||||
// 工具形态的假实现:返回「只有渲染后才看得见」的那句话。
|
||||
return map[string]any{"description": b3RenderOnlyText, "text": b3RenderOnlyText}, nil
|
||||
},
|
||||
)
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
return &b3Fixture{
|
||||
adapter: adapter,
|
||||
outbox: outbox,
|
||||
shot: shotPath,
|
||||
loop: loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
// ← 生产接线就是这一行(见 wire_runtime.go 的接线状态注释)。
|
||||
TransformContext: a.browserShotTransform(resolver),
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// browsercapShot 造与落盘结论同形的截图值。
|
||||
func browsercapShot(path string, size int) browsercap.Shot {
|
||||
return browsercap.Shot{Path: path, Bytes: size, Width: 6, Height: 4, Format: "png",
|
||||
DimensionsKnown: true}
|
||||
}
|
||||
|
||||
// TestBrowserShotClosure_SightedModelReceivesTheImage 是判据 2 的正面:
|
||||
// 主模型能收图 ⇒ 模型请求里**确实有 image 部件**,且是服务端内联的 data: URI。
|
||||
func TestBrowserShotClosure_SightedModelReceivesTheImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newB3Fixture(t, true)
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session,
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(fx.adapter.reqs) == 0 {
|
||||
t.Fatal("没有捕获到模型请求")
|
||||
}
|
||||
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
|
||||
part, ok := findImagePart(req.Messages)
|
||||
if !ok {
|
||||
t.Fatalf("模型请求里没有 image 部件 —— 截图没有进上下文(判据 2 不成立);消息数=%d",
|
||||
len(req.Messages))
|
||||
}
|
||||
if !strings.HasPrefix(part.ImageURL, "data:image/png;base64,") {
|
||||
t.Errorf("image 部件不是内联的 data: URI(云 provider 取不到本地路径):%.60s", part.ImageURL)
|
||||
}
|
||||
if !imagePartIsOnUserMessage(req.Messages) {
|
||||
t.Error("image 部件不在 user 消息上:Anthropic/DeepSeek 只接受 user 消息里的图像部件")
|
||||
}
|
||||
if note := lastUserText(req.Messages); !strings.Contains(note, fx.shot) {
|
||||
t.Errorf("附件说明里没给出截图路径(模型无法据此裁区域精读):%q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserShotClosure_TextOnlyModelGetsToolFormText 是判据 2 的另一条路:
|
||||
// 主模型读不了图 ⇒ 图**不进**上下文,进去的是 vision 工具的读数(「渲染后才出现的文字」)。
|
||||
func TestBrowserShotClosure_TextOnlyModelGetsToolFormText(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newB3Fixture(t, false)
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session,
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
|
||||
if _, hasImage := findImagePart(req.Messages); hasImage {
|
||||
t.Error("主模型读不了图时不该把 image 部件塞进上下文(那是无意义的 token 与 400 风险)")
|
||||
}
|
||||
text := allText(req.Messages)
|
||||
if !strings.Contains(text, b3RenderOnlyText) {
|
||||
t.Errorf("工具形态的文字没进上下文:%q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserShotClosure_ShotIsConsumedOnce 钉住「取走即消费」:同一张截图不会被送到两次
|
||||
// (重复送图既费预算,又会诱导模型以为「又拍了一张」)。
|
||||
func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newB3Fixture(t, true)
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, "看看这一页")}
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
|
||||
t.Fatalf("第一次 Run: %v", err)
|
||||
}
|
||||
if fx.outbox.Pending(b3Session) != 0 {
|
||||
t.Fatalf("第一次之后箱子里还剩 %d 张(取走即消费被破坏)", fx.outbox.Pending(b3Session))
|
||||
}
|
||||
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
|
||||
t.Fatalf("第二次 Run: %v", err)
|
||||
}
|
||||
if _, ok := findImagePart(fx.adapter.reqs[len(fx.adapter.reqs)-1].Messages); ok {
|
||||
t.Error("同一张截图在第二次回合里又被送了一次")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行。
|
||||
//
|
||||
// 装上之前**Skip 并写明要加什么**(不是失败:本片(Y4)的文件面不含 wire_runtime.go,
|
||||
// 那一行由主会话落地)。装上之后本用例就是正向断言 —— 它拦的是「以后有人把那行删了」,
|
||||
// 因为删掉之后**没有任何运行期症状**:编译照过、回合照跑,只是截图不再进上下文。
|
||||
func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_runtime.go")
|
||||
if !strings.Contains(src, "browserShotTransform(") {
|
||||
t.Skip("接线未应用:请在 wire_runtime.go 的 loop.New(loop.Options{…}) 里加 " +
|
||||
"`TransformContext: a.browserShotTransform(visionResolver),`" +
|
||||
"(B3 闭环的生产挂点;本片无权改该文件)")
|
||||
}
|
||||
if !strings.Contains(src, "TransformContext:") {
|
||||
t.Fatal("wire_runtime.go 里出现了 browserShotTransform,但没挂在 TransformContext 上")
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 断言助手
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// findImagePart 返回请求里的第一个 image 部件。
|
||||
func findImagePart(msgs []model.Message) (model.Content, bool) {
|
||||
for _, m := range msgs {
|
||||
for _, part := range m.Content {
|
||||
if part.Type == model.ContentTypeImage {
|
||||
return part, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.Content{}, false
|
||||
}
|
||||
|
||||
// imagePartIsOnUserMessage 报告 image 部件是否挂在 user 消息上(协议硬要求)。
|
||||
func imagePartIsOnUserMessage(msgs []model.Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, part := range m.Content {
|
||||
if part.Type == model.ContentTypeImage {
|
||||
return m.Role == model.RoleUser
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allText 拼出请求里全部文本(断言工具形态的文字是否进了上下文)。
|
||||
func allText(msgs []model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
b.WriteString(loopText(m))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// lastUserText 取最后一条 user 消息的文本(附件说明挂在它上面)。
|
||||
func lastUserText(msgs []model.Message) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == model.RoleUser {
|
||||
return loopText(msgs[i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package main
|
||||
|
||||
// 本文件是 design/28 §8 **B4(M3 面板入口)的接线验证**(切片 Y4):
|
||||
// 声明真的进了面板目录、六个动作真的能分发、读写面分类正确、保存真的热重载。
|
||||
//
|
||||
// 为什么这些断言必须走**真实面板中枢**(`pluginmgr.NewPanels`)而不是只测函数:
|
||||
//
|
||||
// - 「动作可不可调」由声明推出(`pluginmgr.actionIndex`),viewer 的写面在**快照裁剪**
|
||||
// 里被摘掉(`paneldecl.filterPanel`)—— 两件事都不是本包能自证的;
|
||||
// - 保存的语义是「盘上改了 + 运行里的工具也换了」,只有从**能力注册表**读回工具才能验。
|
||||
//
|
||||
// 真浏览器不在这里:`buildBrowserTool` 只按配置探测**可执行文件是否存在**,因此本文件用
|
||||
// 一个假的可执行文件就能走完装配(真机 e2e 见 `wire_browser_e2e_test.go`,默认 Skip)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
auth "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"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/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// browserPanelFixture 是 B4 的夹具:真 hub + 已注册的 browser 工具(假可执行文件)。
|
||||
type browserPanelFixture struct {
|
||||
app *app
|
||||
hub *pluginmgr.Panels
|
||||
log *slog.Logger
|
||||
out *bytes.Buffer
|
||||
path string
|
||||
}
|
||||
|
||||
// newBrowserPanelFixture 造夹具并完成接线。
|
||||
func newBrowserPanelFixture(t *testing.T) *browserPanelFixture {
|
||||
t.Helper()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
writeBrowserConfig(t, cfg, `{"executable":`+strconv.Quote(exe)+`}`)
|
||||
|
||||
var out bytes.Buffer
|
||||
a := &app{
|
||||
cfg: cfg,
|
||||
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
|
||||
caps: registry.New(),
|
||||
}
|
||||
a.wireBrowser()
|
||||
if _, ok := browserEntry(a); !ok {
|
||||
t.Fatalf("夹具前提不成立:明明可用却没注册(日志:%s)", out.String())
|
||||
}
|
||||
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewPanels: %v", err)
|
||||
}
|
||||
log := logger.WithSource(a.log, "test/panel")
|
||||
wireBrowserPanel(hub, a, log)
|
||||
fx := &browserPanelFixture{
|
||||
app: a, hub: hub, log: log, out: &out,
|
||||
path: browsercfg.DefaultConfigPath(cfg.DataDir),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if entry, err := a.caps.Get(browsertools.ToolName); err == nil {
|
||||
if closer, ok := entry.Value.(interface{ Close() error }); ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
return fx
|
||||
}
|
||||
|
||||
// dispatch 调一次动作(等价于宿主动作端点的分发路径:声明 → 启用 → 处理器)。
|
||||
func (fx *browserPanelFixture) dispatch(t *testing.T, action string, payload any) (any, error) {
|
||||
t.Helper()
|
||||
return fx.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: browsertools.PanelPluginID, Action: action, Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_DeclaresPanelAndActionKinds 钉住声明进了目录,且六个动作的读写面
|
||||
// 分类正确(读面 viewer 可调、写面只 owner/admin —— 这是 §5.4.1 的门)。
|
||||
func TestWireBrowserPanel_DeclaresPanelAndActionKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
groups, err := fx.hub.Groups(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("Groups(owner): %v", err)
|
||||
}
|
||||
if len(groups) != 1 || len(groups[0].Panels) != 1 {
|
||||
t.Fatalf("面板快照形状 = %+v", groups)
|
||||
}
|
||||
item := groups[0].Panels[0]
|
||||
if item.PluginID != browsertools.PanelPluginID || item.Panel.ID != browsertools.PanelIDSettings {
|
||||
t.Fatalf("面板标识 = %s/%s", item.PluginID, item.Panel.ID)
|
||||
}
|
||||
if len(item.Panel.Sections) != 4 {
|
||||
t.Fatalf("区块数 = %d, want 4(配置 / 状态 / 指纹 / 截图)", len(item.Panel.Sections))
|
||||
}
|
||||
wantKinds := map[string]pluginmgr.ActionKind{
|
||||
browsertools.ActionState: pluginmgr.ActionRead,
|
||||
browsertools.ActionShots: pluginmgr.ActionRead,
|
||||
browsertools.ActionFingerprint: pluginmgr.ActionRead,
|
||||
browsertools.ActionSaveConfig: pluginmgr.ActionWrite,
|
||||
browsertools.ActionShoot: pluginmgr.ActionWrite,
|
||||
browsertools.ActionCheck: pluginmgr.ActionWrite,
|
||||
}
|
||||
for name, want := range wantKinds {
|
||||
got, ok := fx.hub.Action(browsertools.PanelPluginID, name)
|
||||
if !ok {
|
||||
t.Errorf("动作 %s 没进声明索引(面板会被宿主拒掉或点了没反应)", name)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("动作 %s 的读写面 = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_ViewerSeesReadOnlyProjection 钉住角色裁剪:viewer 看到的分组里
|
||||
// **没有**表单(写面)与行内动作(写动作),只留只读表格 —— 这是「只读监控投影」的定义。
|
||||
func TestWireBrowserPanel_ViewerSeesReadOnlyProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
groups, err := fx.hub.Groups(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("Groups(viewer): %v", err)
|
||||
}
|
||||
if len(groups) != 1 || len(groups[0].Panels) != 1 {
|
||||
t.Fatalf("viewer 的面板快照形状 = %+v", groups)
|
||||
}
|
||||
for _, s := range groups[0].Panels[0].Panel.Sections {
|
||||
if s.Type == "form" {
|
||||
t.Errorf("viewer 拿到了写表单(%s)", s.ID)
|
||||
}
|
||||
if len(s.RowActions) > 0 {
|
||||
t.Errorf("viewer 拿到了行内写动作(%s:%+v)", s.ID, s.RowActions)
|
||||
}
|
||||
}
|
||||
// 对照:owner 看得到表单与行内动作(否则上面的断言在「什么都没渲染」时也会过)。
|
||||
ownerGroups, err := fx.hub.Groups(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("Groups(owner): %v", err)
|
||||
}
|
||||
forms, rowActions := 0, 0
|
||||
for _, s := range ownerGroups[0].Panels[0].Panel.Sections {
|
||||
if s.Type == "form" {
|
||||
forms++
|
||||
}
|
||||
rowActions += len(s.RowActions)
|
||||
}
|
||||
if forms != 1 || rowActions != 2 {
|
||||
t.Fatalf("owner 看到的表单/行内动作 = %d/%d, want 1/2", forms, rowActions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser 钉住三件事:
|
||||
// 读动作真的能分发、回的是 `{rows:[…]}`、且**没有**启动浏览器会话(面板取数无副作用)。
|
||||
func TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
for _, action := range []string{
|
||||
browsertools.ActionState, browsertools.ActionShots, browsertools.ActionFingerprint,
|
||||
} {
|
||||
res, err := fx.dispatch(t, action, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", action, err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s 结果类型 = %T", action, res)
|
||||
}
|
||||
rows, ok := m["rows"].([]map[string]any)
|
||||
if !ok || rows == nil {
|
||||
t.Fatalf("%s 的 rows = %#v(必须是数组,哪怕是空的)", action, m["rows"])
|
||||
}
|
||||
// 状态与指纹**恒有一行**(事实面),最近截图在没有截图时如实为空表。
|
||||
if action != browsertools.ActionShots && len(rows) == 0 {
|
||||
t.Fatalf("%s 应当至少一行事实:%#v", action, m["rows"])
|
||||
}
|
||||
}
|
||||
tool := browserToolValue(t, fx)
|
||||
if snap := tool.State(); snap.SessionLive {
|
||||
t.Error("读动作启动了浏览器会话:面板取数必须无进程外副作用")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_SaveConfigHotReloads 钉住「保存即生效」:落盘 + 换掉运行中的工具
|
||||
// (面板的意义就是不改 `.env`、不重启)。
|
||||
func TestWireBrowserPanel_SaveConfigHotReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
res, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{
|
||||
browsertools.FieldViewportWidth: float64(1600),
|
||||
browsertools.FieldViewportHeight: float64(900),
|
||||
browsertools.FieldIdleTimeout: float64(120),
|
||||
browsertools.FieldHeadless: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("saveBrowserConfig: %v", err)
|
||||
}
|
||||
m, _ := res.(map[string]any)
|
||||
if m["applied"] != true || m["registered"] != true {
|
||||
t.Fatalf("保存结论 = %#v(要求盘上与运行中都生效)", m)
|
||||
}
|
||||
snap := browserToolValue(t, fx).State()
|
||||
if snap.ViewportWidth != 1600 || snap.ViewportHeight != 900 || snap.IdleTimeoutSeconds != 120 || !snap.Headless {
|
||||
t.Fatalf("热重载后的生效配置 = %+v,与保存内容不一致", snap)
|
||||
}
|
||||
raw, err := os.ReadFile(fx.path)
|
||||
if err != nil {
|
||||
t.Fatalf("读回配置: %v", err)
|
||||
}
|
||||
var onDisk browsercfg.Config
|
||||
if err := json.Unmarshal(raw, &onDisk); err != nil {
|
||||
t.Fatalf("配置不是合法 JSON: %v", err)
|
||||
}
|
||||
if onDisk.ViewportWidth != 1600 || !onDisk.Headless {
|
||||
t.Fatalf("盘上的配置 = %+v", onDisk)
|
||||
}
|
||||
// 面板默认值跟着刷新(前端提交成功后会重取快照,表单因此显示「刚落盘的那份」)。
|
||||
groups, _ := fx.hub.Groups(auth.RoleOwner)
|
||||
for _, s := range groups[0].Panels[0].Panel.Sections {
|
||||
if s.Type != "form" {
|
||||
continue
|
||||
}
|
||||
for _, f := range s.Fields {
|
||||
if f.Key == browsertools.FieldViewportWidth && f.Default != 1600 {
|
||||
t.Errorf("面板默认值没刷新:viewportWidth=%v", f.Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_SaveConfigRejectsUnknownField 钉住「未知字段不许静默忽略」:
|
||||
// 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
|
||||
func TestWireBrowserPanel_SaveConfigRejectsUnknownField(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
before, _ := os.ReadFile(fx.path)
|
||||
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{"nope": "x"},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("失败形状 = %+v, want invalid_payload/400", ae)
|
||||
}
|
||||
after, _ := os.ReadFile(fx.path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("被拒的保存改动了盘上的配置文件")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable 钉住事务:保存前有可用工具而新配置
|
||||
// 让它不可用(路径写错)⇒ **回滚**且如实报冲突,绝不让「盘上的」与「跑着的」静默拆开。
|
||||
func TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
before, _ := os.ReadFile(fx.path)
|
||||
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{
|
||||
browsertools.FieldExecutable: filepath.Join(t.TempDir(), "no-such-browser.exe"),
|
||||
},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != browserCodeConfigRejected || ae.Status != http.StatusConflict {
|
||||
t.Fatalf("失败形状 = %+v, want %s/409", ae, browserCodeConfigRejected)
|
||||
}
|
||||
after, _ := os.ReadFile(fx.path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("没能生效的保存没有回滚:盘上留下了会让工具消失的配置")
|
||||
}
|
||||
if _, ok := browserEntry(fx.app); !ok {
|
||||
t.Error("回滚之后原来的工具也不见了(应当原样保留)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget 钉住两个写动作的诚实下限:
|
||||
// 没有打开的页面时「拍一张」如实报 browser_session_not_open,绝不偷偷替模型开一页。
|
||||
func TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
_, err := fx.dispatch(t, browsertools.ActionShoot, map[string]any{
|
||||
"row": map[string]any{"source": "本机 Chrome"},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != browserCodeNoSession || ae.Status != http.StatusConflict {
|
||||
t.Fatalf("拍一张的失败形状 = %+v, want %s/409", ae, browserCodeNoSession)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_WithoutToolIsHonest 钉住「没有浏览器来源」时的两个写动作:如实报
|
||||
// browser_unavailable(而不是 500 / 假成功),且声明仍然在(面板可以配,配好保存即生效)。
|
||||
func TestWireBrowserPanel_WithoutToolIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
var out bytes.Buffer
|
||||
a := &app{cfg: cfg, log: logger.New(logger.Options{Out: &out, Level: "debug"}), caps: registry.New()}
|
||||
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewPanels: %v", err)
|
||||
}
|
||||
wireBrowserPanel(hub, a, logger.WithSource(a.log, "test/panel"))
|
||||
groups, gerr := hub.Groups(auth.RoleOwner)
|
||||
if gerr != nil || len(groups) != 1 || len(groups[0].Panels) != 1 {
|
||||
t.Fatalf("没有工具时面板也该声明(可以在面板里配):%+v, %v", groups, gerr)
|
||||
}
|
||||
for _, action := range []string{browsertools.ActionShoot, browsertools.ActionCheck} {
|
||||
_, derr := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: browsertools.PanelPluginID, Action: action,
|
||||
})
|
||||
ae := requireActionError(t, derr)
|
||||
if ae.Code != browserCodeUnavailable {
|
||||
t.Errorf("%s 的失败形状 = %+v, want %s", action, ae, browserCodeUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 助手
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// browserToolValue 从能力面取回 browser 工具。
|
||||
func browserToolValue(t *testing.T, fx *browserPanelFixture) *browsertools.Tool {
|
||||
t.Helper()
|
||||
entry, err := fx.app.caps.Get(browsertools.ToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("能力面里没有 browser 工具: %v", err)
|
||||
}
|
||||
tool, ok := entry.Value.(*browsertools.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("能力面里的值不是 browser 工具: %T", entry.Value)
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
// requireActionError 断言错误是一条**结构化动作失败**(错误码 + 文案 + 状态)。
|
||||
func requireActionError(t *testing.T, err error) *pluginmgr.ActionError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("期望一个结构化失败,得到 nil")
|
||||
}
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v(%T),不是 structured ActionError", err, err)
|
||||
}
|
||||
if ae.Message == "" {
|
||||
t.Error("结构化失败没有文案(§5.4.6 要求错误码与文案分离且都在)")
|
||||
}
|
||||
return ae
|
||||
}
|
||||
|
||||
// TestWireBrowserPanel_ConfigFileIsPrivate 钉住落盘权限:browser.json 可能带显式 CDP 端点
|
||||
// 与 profile 位置,按 0600 落盘(`browsercfg.Save` 的口径,这里从**面板保存**这条路上验)。
|
||||
func TestWireBrowserPanel_ConfigFileIsPrivate(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
if _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
|
||||
}); err != nil {
|
||||
t.Fatalf("saveBrowserConfig: %v", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows 不体现 POSIX 权限位(仓库既有的 store_test.go 同口径跳过)。
|
||||
t.Skip("Windows 不体现 POSIX 权限位;0600 的判定在 store_test.go 覆盖")
|
||||
}
|
||||
info, err := os.Stat(fx.path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat 配置: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("配置权限 = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile 钉住「坏配置不静默顶替」:
|
||||
// 文件非法时 `currentConfig` 返回错误(面板据此如实告知),不悄悄回落到默认值。
|
||||
func TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newBrowserPanelFixture(t)
|
||||
if err := os.WriteFile(fx.path, []byte("{ not json"), 0o600); err != nil {
|
||||
t.Fatalf("写坏配置: %v", err)
|
||||
}
|
||||
if _, err := fx.dispatch(t, browsertools.ActionState, map[string]any{}); err != nil {
|
||||
t.Fatalf("读动作不该因为配置坏而失败(它用默认值兜底显示现状): %v", err)
|
||||
}
|
||||
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
|
||||
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
|
||||
})
|
||||
ae := requireActionError(t, err)
|
||||
if ae.Code != browserCodeConfigUnreadable {
|
||||
t.Fatalf("坏配置下保存的失败形状 = %+v, want %s", ae, browserCodeConfigUnreadable)
|
||||
}
|
||||
raw, _ := os.ReadFile(fx.path)
|
||||
if string(raw) != "{ not json" {
|
||||
t.Error("坏配置在保存失败后被改写了(不该动它)")
|
||||
}
|
||||
if !errors.Is(err, browsercfg.ErrInvalidConfig) {
|
||||
t.Logf("note: 错误链未含 ErrInvalidConfig(%v)", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user