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

+113 -58
View File
@@ -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
}
+214
View File
@@ -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
}
+261
View File
@@ -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 ""
}
+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
}
+246
View File
@@ -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)
}
}
+415
View File
@@ -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)
}
}
+20
View File
@@ -94,6 +94,14 @@ type Manager struct {
closed bool
source string
// sessionOpenedAt 是当前会话的建立时间(面板的只读事实面用,见 state.go)。
sessionOpenedAt time.Time
// shots 是最近截图统计(**非内容**,最新在前,最多 maxShotRecords 条)。
shots []ShotRecord
// fp / fpAt 是最后一次指纹自检的读数与时间(面板显示「上次自检」用)。
fp *Fingerprint
fpAt time.Time
stopReaper chan struct{}
reaperDone chan struct{}
closeOnce sync.Once
@@ -171,6 +179,9 @@ type PageResult struct {
// Screenshot 抓一张视口截图并落盘(0600)。超过配置上限时先降级为 JPEG 重抓,
// 仍超限则**如实报错**,绝不交出半张坏图。
//
// 截图统计(字节/尺寸/格式)同时记进内存(`State().Shots`),供面板的只读事实面显示
// 「最近拍到了什么」—— 面板不读文件、不进上下文,只看统计(state.go 口径 2)。
func (m *Manager) Screenshot(ctx context.Context) (Shot, error) {
var shot Shot
err := m.withSession(ctx, func(s *session) error {
@@ -183,6 +194,7 @@ func (m *Manager) Screenshot(ctx context.Context) (Shot, error) {
return err
}
shot = saved
m.recordShotLocked(saved, time.Now())
return nil
})
if err != nil {
@@ -214,6 +226,9 @@ func (m *Manager) capture(ctx context.Context, s *session) ([]byte, string, erro
// Fingerprint 在**隔离世界**里读一次页面指纹(reference/10 §4.1 的 L2~L5 自检项),
// 用来证明「这次跑的是有头、webdriver 关、屏幕/时区/语言与设定一致」。
//
// 读数会被记进内存(`State().Fingerprint`),面板因此能显示「上次自检」而不必每次都
// 起浏览器(state.go 口径 1:面板取数无副作用,自检是一条显式动作)。
func (m *Manager) Fingerprint(ctx context.Context) (Fingerprint, error) {
var fp Fingerprint
err := m.withSession(ctx, func(s *session) error {
@@ -227,6 +242,7 @@ func (m *Manager) Fingerprint(ctx context.Context) (Fingerprint, error) {
if err != nil {
return Fingerprint{}, err
}
m.recordFingerprint(fp)
return fp, nil
}
@@ -273,6 +289,7 @@ func (m *Manager) ensureLocked(ctx context.Context) (*session, error) {
}
m.sess = s
m.lastUsed = time.Now()
m.sessionOpenedAt = m.lastUsed
m.log.Info("browser session opened", "source", m.source, "headless", m.cfg.headless)
return s, nil
}
@@ -292,6 +309,7 @@ func (m *Manager) recycleLocked(reason Reason) {
}
m.sess.Close()
m.sess = nil
m.sessionOpenedAt = time.Time{}
m.log.Info("browser session recycled", "reason", reason, "detail", reason.Message())
}
@@ -304,6 +322,7 @@ func (m *Manager) dropLocked(cause error) {
m.log.Warn("browser session dropped after connection error", "error", cause)
m.sess.Close()
m.sess = nil
m.sessionOpenedAt = time.Time{}
}
// reapLoop 是空闲清扫协程:滴答间隔跟随空闲上限的一半(夹在具名上下界之间)。
@@ -351,6 +370,7 @@ func (m *Manager) Close() error {
if m.sess != nil {
m.sess.Close()
m.sess = nil
m.sessionOpenedAt = time.Time{}
}
m.mu.Unlock()
})
+149
View File
@@ -0,0 +1,149 @@
package browser
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// 本文件是 design/28 §8 的 **B4(M3 面板入口)在驱动层的事实面**:面板要显示与
// 操作的那一组只读事实、以及「最近截图」这类**非内容**统计。
//
// 三条口径:
//
// 1. **面板取数不启动浏览器**:`State` 只读内存里已有的事实(配置、可用性、会话是否活着、
// 上次指纹自检、最近截图统计)。真正的自检(要起浏览器)是**一条显式动作**由主人点,
// 不是「打开面板就顺手起一个 Chrome」—— 面板的只读区块连 viewer 都能看(design/09
// §5.4.1),让「看一眼」产生进程外副作用是明确的越界;
// 2. **只存非内容事实**:这里存的是字节数/尺寸/格式/时间,**绝不存字节本身、绝不存页面
// 内容或 URL**。截图的正文只以文件形式存在(0600),面板只报「拍到了几张、多大」;
// 3. **有界**:最近截图统计最多留 `maxShotRecords` 条(环),指纹只留最后一次。
// maxShotRecords 是「最近截图」保留的条数。面板是一张只读表(排障用),20 条足够看出
// 「刚刚那一下到底拍成功没有」,再多只是把页面拉长。
const maxShotRecords = 20
// ShotRecord 是一次截图的**统计事实**(不含路径、不含内容)。
type ShotRecord struct {
// At 是截图落盘时间。
At time.Time
// Bytes 是落盘字节数。
Bytes int
// Width / Height 是真实像素尺寸;DimensionsKnown=false 时这两个数不可信。
Width int
Height int
// DimensionsKnown 报告宽高是否从字节里解出来。
DimensionsKnown bool
// Format 是实际编码("png" / "jpeg")。
Format string
// Degraded 报告这张图是否是「PNG 超上限、降级成 JPEG」的产物。
Degraded bool
}
// StateSnapshot 是面板与自查工具要的一组只读事实。
//
// 字段全部是**当前进程里的事实**(配置取自生效配置、可用性取自构造时的探测结论),
// 因此它不会自己发现新装了一个 Chrome 或刚改过的配置文件 —— 那需要重启或面板保存
// 触发的热重载,两种情形都会重建 Manager,快照随之更新。
type StateSnapshot struct {
// Source 是来源描述(本机有头 / 容器端点),不含凭据。
Source string
// Available 报告当前有没有可用来源(不可用时工具根本不会被注册)。
Available bool
// Closed 报告会话管理器是否已关闭(`Close` 之后)。
Closed bool
// Headless 是生效档(false = 有头,design/28 §2 的默认档)。
Headless bool
// ViewportWidth / ViewportHeight 是生效视口。
ViewportWidth int
ViewportHeight int
// ProfileDir 是持久化 profile 目录(含登录态,0700;**不进上下文**)。
ProfileDir string
// ShotsDir 是截图落点目录。
ShotsDir string
// Concurrency 是生效并发上限:**恒为 1**(同一 profile 起两个实例会互锁,
// design/28 §3)。面板如实显示它,而不是把它当可调项。
Concurrency int
// TimeoutSeconds / IdleTimeoutSeconds 是动作超时与空闲回收秒数。
TimeoutSeconds int
IdleTimeoutSeconds int
// ScreenshotMaxBytes 是单张截图字节上限。
ScreenshotMaxBytes int
// SessionLive 报告此刻是否有一个活着的浏览器会话。
SessionLive bool
// SessionOpenedAt 是当前会话的建立时间(SessionLive=false 时为零值)。
SessionOpenedAt time.Time
// Shots 是最近截图统计,**最新在前**。
Shots []ShotRecord
// Fingerprint / FingerprintAt 是最后一次指纹自检的读数与时间;
// 从未自检过时 Fingerprint 为 nil(面板如实显示「未自检」,不编造)。
Fingerprint *Fingerprint
FingerprintAt time.Time
}
// State 返回当前只读事实快照。它不启动浏览器、不发网络请求(见文件头口径 1)。
func (m *Manager) State() StateSnapshot {
if m == nil {
return StateSnapshot{}
}
m.mu.Lock()
defer m.mu.Unlock()
out := StateSnapshot{
Source: m.source,
Available: m.d != nil,
Closed: m.closed,
Headless: m.cfg.headless,
ViewportWidth: m.cfg.viewportW,
ViewportHeight: m.cfg.viewportH,
ProfileDir: m.cfg.profileDir,
ShotsDir: m.cfg.shotsDir,
Concurrency: browsercfg.DefaultConcurrency,
TimeoutSeconds: int(m.cfg.timeout / time.Second),
IdleTimeoutSeconds: int(m.cfg.idle / time.Second),
ScreenshotMaxBytes: m.cfg.shotMaxBytes,
SessionLive: m.sess != nil,
SessionOpenedAt: m.sessionOpenedAt,
Fingerprint: cloneFingerprint(m.fp),
FingerprintAt: m.fpAt,
}
out.Shots = append([]ShotRecord(nil), m.shots...)
return out
}
// recordShotLocked 记一条截图统计(最新在前、最多 maxShotRecords 条)。调用方必须持有 m.mu。
func (m *Manager) recordShotLocked(shot Shot, at time.Time) {
rec := ShotRecord{
At: at,
Bytes: shot.Bytes,
Width: shot.Width,
Height: shot.Height,
DimensionsKnown: shot.DimensionsKnown,
Format: shot.Format,
Degraded: shot.Degraded,
}
m.shots = append([]ShotRecord{rec}, m.shots...)
if len(m.shots) > maxShotRecords {
m.shots = m.shots[:maxShotRecords]
}
}
// recordFingerprint 记下最后一次指纹自检的读数(面板据此显示「上次自检」的时间与读数)。
func (m *Manager) recordFingerprint(fp Fingerprint) {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.fp = &fp
m.fpAt = time.Now()
}
// cloneFingerprint 深拷一份指纹读数(含切片),使 `State` 的返回值与内部状态互不影响。
func cloneFingerprint(fp *Fingerprint) *Fingerprint {
if fp == nil {
return nil
}
out := *fp
out.Languages = append([]string(nil), fp.Languages...)
return &out
}
+145
View File
@@ -0,0 +1,145 @@
package browser
// 本文件钉住 B4 面板的**驱动层事实面**(design/28 §8 B4;形状见 state.go):
// 取数**不启动浏览器**、最近截图统计有界且最新在前、指纹缓存是深拷。
//
// 「面板取数无副作用」这条是硬口径:面板的只读区块连 viewer 都能看(design/09 §5.4.1),
// 让「打开页面看一眼」顺手起一个 Chrome 是明确的越界。因此这里用**计数型假 dialer**
// 证明 `State()` 一次都没碰过启动路径(比「看起来没起」强)。
import (
"context"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// countingDialer 记录 Dial 调用次数:任何一次 `State()` 都不该让它 +1。
type countingDialer struct {
dials atomic.Int32
}
func (d *countingDialer) Dial(context.Context) (string, func(), error) {
d.dials.Add(1)
return "", func() {}, nil
}
func (d *countingDialer) Describe() string { return "counting-dialer" }
func (d *countingDialer) Local() bool { return false }
// newStateFixture 造一个带可用 dialer 的管理器(配置走默认值)。
func newStateFixture(t *testing.T) (*Manager, *countingDialer) {
t.Helper()
m := NewManager(Options{
Availability: browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Detail: "fixture", Headless: true,
},
Config: browsercfg.Defaults(`C:\data`),
DataDir: `C:\data`,
})
t.Cleanup(func() { _ = m.Close() })
d := &countingDialer{}
m.d = d
return m, d
}
// TestManagerState_ReadsFactsWithoutLaunching 钉住「取数无副作用」:State() 只读字段,
// 一次都不碰启动路径。
func TestManagerState_ReadsFactsWithoutLaunching(t *testing.T) {
t.Parallel()
m, d := newStateFixture(t)
for i := 0; i < 3; i++ {
snap := m.State()
if !snap.Available {
t.Fatalf("可用来源下 Available 应为 true:%+v", snap)
}
if snap.SessionLive {
t.Fatalf("State() 建出了会话:%+v", snap)
}
if snap.Headless != browsercfg.DefaultHeadless {
t.Errorf("Headless = %v, want %v", snap.Headless, browsercfg.DefaultHeadless)
}
if snap.ViewportWidth != browsercfg.DefaultViewportWidth || snap.ViewportHeight != browsercfg.DefaultViewportHeight {
t.Errorf("视口 = %dx%d", snap.ViewportWidth, snap.ViewportHeight)
}
if snap.Concurrency != browsercfg.DefaultConcurrency {
t.Errorf("并发上限 = %d, want %d(profile 锁)", snap.Concurrency, browsercfg.DefaultConcurrency)
}
if snap.Shots != nil {
t.Errorf("没有截图时 Shots 应为空:%+v", snap.Shots)
}
}
if got := d.dials.Load(); got != 0 {
t.Fatalf("State() 触发了 %d 次拨号(面板取数必须无进程外副作用)", got)
}
}
// TestManagerState_ShotRingIsBoundedAndNewestFirst 钉住最近截图统计的形状:
// 有界(不随运行时间无界增长)且最新在前(面板第一行是「刚才那一下」)。
func TestManagerState_ShotRingIsBoundedAndNewestFirst(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
base := time.Date(2026, 10, 6, 21, 0, 0, 0, time.UTC)
for i := 0; i < maxShotRecords+5; i++ {
m.mu.Lock()
m.recordShotLocked(Shot{Bytes: i + 1, Width: 8, Height: 8, DimensionsKnown: true, Format: "png"}, base.Add(time.Duration(i)*time.Second))
m.mu.Unlock()
}
shots := m.State().Shots
if len(shots) != maxShotRecords {
t.Fatalf("保留条数 = %d, want %d", len(shots), maxShotRecords)
}
if shots[0].Bytes != maxShotRecords+5 {
t.Fatalf("最新的一条不在最前:首条字节 = %d", shots[0].Bytes)
}
for i := 1; i < len(shots); i++ {
if !shots[i-1].At.After(shots[i].At) {
t.Fatalf("行序不是最新在前:%d 与 %d", i-1, i)
}
}
}
// TestManagerState_FingerprintIsCachedByValue 钉住指纹缓存是**深拷**:调用方改了返回的
// 读数不该改到管理器里的那一份(否则面板显示会被别人的改动悄悄污染)。
func TestManagerState_FingerprintIsCachedByValue(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
m.recordFingerprint(Fingerprint{Webdriver: false, Languages: []string{"zh-CN", "en"}, Timezone: "Asia/Shanghai"})
first := m.State()
if first.Fingerprint == nil || first.FingerprintAt.IsZero() {
t.Fatalf("指纹没被记下来:%+v", first)
}
first.Fingerprint.Languages[0] = "mutated"
if got := m.State().Fingerprint.Languages[0]; got != "zh-CN" {
t.Fatalf("指纹缓存被外部改动污染了:%q", got)
}
}
// TestManagerState_ClosedIsReported 钉住「已关闭」如实上报(面板据此显示「已关闭」而不是
// 假装还可用 —— 关闭之后任何动作都会报 ErrNoSession)。
func TestManagerState_ClosedIsReported(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
if err := m.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
snap := m.State()
if !snap.Closed {
t.Fatalf("关闭后 Closed = false:%+v", snap)
}
if snap.SessionLive {
t.Error("关闭后不该报会话活着")
}
}
// TestManagerState_NilManagerIsSafe 钉住 nil 管理器的兜底(面板在「工具没注册」那条路上
// 仍要取事实)。
func TestManagerState_NilManagerIsSafe(t *testing.T) {
t.Parallel()
var m *Manager
if snap := m.State(); snap.Available || snap.Closed {
t.Fatalf("nil 管理器的快照 = %+v", snap)
}
}
+31 -7
View File
@@ -68,16 +68,29 @@ func (r *MessageResolver) Resolve(ctx context.Context, msg model.Message) (model
return model.Message{Role: msg.Role, Content: out}, nil
}
// resolveImage 选出一条路由:要么保留图像(KindMain),
// 要么经 vision/OCR 工具把它折叠为文字描述。
func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) {
// describePrompt 是走 vision 工具路由时给视觉模型的默认指令。它是**命名常量**
// 而不是散在两处的字面量:`resolveImage` 与 `ShotAttacher` 必须用同一句,
// 否则同一种图会因为「谁发的」而得到两种提问方式(结果不可比)。
const describePrompt = "Describe this image in detail."
// decision 是路由判定的唯一读取点:把「主模型能力 → Router」这一步收在一处,
// 使 resolveImage(用户附件)与 ShotAttacher(截图附件)不可能各自算一次而分叉。
func (r *MessageResolver) decision() Decision {
if r == nil || r.router == nil {
return Decision{Kind: KindUnsupported, Reason: "no vision router configured"}
}
target := ModelInfo{}
if r.info != nil {
target = r.info()
}
decision := r.router.Route(target, RouteRequest{})
switch decision.Kind {
case KindMain:
return r.router.Route(target, RouteRequest{})
}
// resolveImage 选出一条路由:要么保留图像(KindMain),
// 要么经 vision/OCR 工具把它折叠为文字描述。
func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) {
decision := r.decision()
if decision.Kind == KindMain {
// 支持视觉的主模型直接看到这张图;但 URL 与本地路径必须先由服务端
// 取回并内联为 data: URI——云 provider 取不到自部署的局域网地址
// (如 http://192.168.x.x:10942/api/v1/files/…),Anthropic 协议更是
@@ -90,10 +103,21 @@ func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (mo
return model.Content{}, err
}
return model.Content{Type: model.ContentTypeImage, ImageURL: src}, nil
}
return r.resolveToolRoute(ctx, imageURL, describePrompt, decision)
}
// resolveToolRoute 执行工具形态的那两条路由(vision / OCR):图像不进主模型
// 上下文,只把工具产出的文字折叠成一个文本部件。没有工具可用时如实报
// ErrUnsupportedSource(**绝不**悄悄把图像留给一个读不了图的主模型)。
func (r *MessageResolver) resolveToolRoute(
ctx context.Context, imageURL, prompt string, decision Decision,
) (model.Content, error) {
switch decision.Kind {
case KindVisionTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
"image": imageURL,
"prompt": "Describe this image in detail.",
"prompt": prompt,
}, "description")
case KindOCRTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
+296
View File
@@ -0,0 +1,296 @@
package vision
import (
"context"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 design/28 §7 判据 2(M4 闭环)的**视觉侧**:把一张**已落盘的页面截图**
// 变成「能进上下文的一段内容」。
//
// 为什么它必须存在(V5 留下的缺口,design/28 §8 的 B3):截图工具此前只把落盘路径交给
// 模型,由模型自己再调一次 vision —— 那不是闭环,是「模型记得的话就补一刀」。闭环的
// 定义(§1 M4)是:截图 → **自动**进上下文 ⇒ 主模型「看见」这一页,而不是读一页 HTML。
//
// 两条路都复用既有机制,不新造第二套:
//
// 1. **主模型能收图**(models.json 的 `multimodal` 声明,裁决 14.4.1 的口径):把截图
// 内联成 data: URI 的 image 部件(与用户附图走**同一条** `inlineImageCached`),
// 它对主模型而言就是一张附件图(design/24 的附件语义);
// 2. **主模型读不了图**:走**工具形态**适配器(vision / OCR 工具),把图折叠成一段
// 文字描述(design/28 §5 的表格行:截图的下游 = vision)。
//
// 大图先过裁切(主人点名的要求,design/28 §5 的 `io/crop` 那一行):截图的落盘上限
// (`browsercfg.DefaultScreenshotMaxBytes` = 8 MiB,可配到 32 MiB)**大于**内联解码层
// 的上限(`MaxInlineImageBytes` = 5 MiB),所以存在「拍得下、但内联不进去」的真区间。
// 这一段的处理写在 `imageForMain`:先按预算裁一块送进去,并**如实标注**裁掉了什么、
// 完整图在哪 —— 模型因此仍能用 `image-crop` 自取其余区域(crop 的两条注释正是这个用法)。
//
// 三条红线(design/28 §4):
// - 截图路径**只**从调用方得到(本文件不做目录扫描、不猜文件名);
// - 超限、缺工具、裁切失败一律**如实**降级/报错,绝不交出半张坏图,也绝不假装成功;
// - 任何日志/错误里都**不含图片内容**(错误只带上限与本次长度)。
const (
// ShotRouteMain 报告这条附件走的是「内联给主模型」。
ShotRouteMain = "main"
// ShotRouteVisionTool 报告走的是 vision 工具形态(图不进主模型上下文)。
ShotRouteVisionTool = "vision_tool"
// ShotRouteOCRTool 报告走的是 OCR 工具形态。
ShotRouteOCRTool = "ocr_tool"
)
// 裁切规则里的具名常量(都是「实际后果」倒推出来的,不是随手取的数)。
const (
// shotCropMinRatio 是裁切后至少保留原图高度的比例。再小就只剩一条边,送进去
// 的图读不出页面结构(等于没说),此时宁可走工具形态。
shotCropMinRatio = 0.25
// shotCropMinHeight 是裁切高度的绝对下限(像素)。极扁的页面按比例算出来的
// 一条窄带没有信息量,因此给一个下限;仍算不出就放弃裁切。
shotCropMinHeight = 120
// shotCropMaxAttempts 是最多裁几次:每次按预算比缩小,第一次不成再试一次
// 就够(估算偏差来自 PNG 的压缩率变化,不会差一个数量级)。多次裁剪会把
// 一次截图变成多次工具调用,反而不如走工具形态。
shotCropMaxAttempts = 2
)
// ShotRef 是一张**已落盘**的页面截图(`browser` 能力的产物,design/28 §3)。
//
// 为什么带着统计字段而不是只有一个路径:裁切区域与「能不能内联」都要按真实字节与
// 真实像素算(`internal/capability/browser/shot.go` 从字节里解出来,不靠视口配置冒充)。
// 零值 `Bytes`/`Width`/`Height` 表示调用方不知道 —— 那几档**不猜**:只按路径内联或
// 直接走工具形态,绝不编一个区域去裁。
type ShotRef struct {
// Path 是截图的本地绝对路径(0600 落盘)。
Path string
// Bytes 是落盘字节数。
Bytes int
// Width / Height 是从字节里解出的真实像素尺寸。
Width int
Height int
// Format 是实际编码("png" / "jpeg")。
Format string
}
// ShotCropper 是「按区域裁一张图,返回可内联的 data: URI」的接缝。
//
// 生产实现是既有的 `image-crop` 工具(经工具分发器调用,`plugins/tools/io/crop.go`);
// nil 表示这台机器上没有裁切能力 —— 那时大图**不裁**,如实改走工具形态(见 Attach)。
// 用接口而不是直接依赖 io 包:本包不持有工具注册表,也不需要知道裁剪是怎么实现的。
type ShotCropper interface {
// CropRegion 裁出 (x, y, w, h) 并返回 `data:<mime>;base64,…`。
CropRegion(ctx context.Context, path string, x, y, w, h int) (string, error)
}
// ShotAttachment 是一张截图进上下文的结论。它是**可观测的事实**:路由走了哪条、
// 图被怎么处理过,都写在这里,由调用方(loop 的装配钩子)如实带进上下文与日志。
type ShotAttachment struct {
// Content 是挂到消息上的内容部件:image(内联给主模型)或 text(工具形态的描述)。
Content model.Content
// Route 是本次实际走的路由(本文件的 ShotRoute* 常量)。
Route string
// Note 是给模型看的一句事实说明(图被裁过、走的是描述而不是原图…),
// 与内容部件放在同一条消息里 —— 模型据此才知道自己看到的是不是全图。
Note string
// Cropped 报告这张图是否被裁过(Route 仍是 main)。
Cropped bool
// CropRegion 是被裁下来的区域("x,y,w,h");未裁时为空。
CropRegion string
}
// ShotAttacher 把页面截图变成附件内容。它复用 `MessageResolver` 的路由与内联实现
// (同一个 Router、同一份 data: URI 缓存),因此截图与用户附图**不会**走出两种行为。
type ShotAttacher struct {
resolver *MessageResolver
cropper ShotCropper
// budget 是内联预算(解码后字节)。0 ⇒ `MaxInlineImageBytes`。
budget int
}
// ShotAttacherOption 配置 ShotAttacher。
type ShotAttacherOption func(*ShotAttacher)
// WithShotCropper 注入裁切能力(生产是 `image-crop` 工具;nil = 没有)。
func WithShotCropper(c ShotCropper) ShotAttacherOption {
return func(s *ShotAttacher) { s.cropper = c }
}
// WithShotInlineBudget 覆盖内联预算(解码后字节)。非正值原样保留为 0 ⇒ 用默认上限。
// 存在的意义是让「大图」这一档**可测**:真造一张 >5 MiB 的 PNG 既慢又占磁盘。
func WithShotInlineBudget(n int) ShotAttacherOption {
return func(s *ShotAttacher) { s.budget = n }
}
// NewShotAttacher 构造附件器。resolver 为 nil 时构造成功但 Attach 一律报
// ErrNotConfigured(**不静默**:那会让「截图静默不进上下文」变成一个查不出的谜)。
func NewShotAttacher(resolver *MessageResolver, opts ...ShotAttacherOption) *ShotAttacher {
s := &ShotAttacher{resolver: resolver}
for _, o := range opts {
if o != nil {
o(s)
}
}
return s
}
// inlineBudget 返回本次生效的内联预算。
func (s *ShotAttacher) inlineBudget() int {
if s == nil || s.budget <= 0 {
return MaxInlineImageBytes
}
return s.budget
}
// Attach 把一张截图变成附件内容。返回的 Note 一定非空(模型据此知道自己看到的是什么)。
//
// 路由顺序(每一步失败都**如实**报告,绝不回头假装成功):
//
// ① 主模型能收图 + 图在内联预算内 ⇒ 直接内联(Route=main)
// ② 主模型能收图 + 图超预算 ⇒ 先裁一块内联(Route=main,Cropped=true)
// ③ 裁不了 / 裁完仍超预算 / 主模型读不了图 ⇒ 工具形态(Route=vision_tool / ocr_tool)
// ④ 以上都不成立(没有工具、没有路由)⇒ 返回错误,由调用方如实降级
func (s *ShotAttacher) Attach(ctx context.Context, shot ShotRef) (ShotAttachment, error) {
if s == nil || s.resolver == nil {
return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w", ErrNotConfigured)
}
path := strings.TrimSpace(shot.Path)
if path == "" {
return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 截图路径为空", ErrUnsupportedSource)
}
decision := s.resolver.decision()
if decision.Kind == KindMain {
if att, err := s.imageForMain(ctx, shot, path); err == nil {
return att, nil
}
// 内联这条路走不通(文件读不出来 / 超限且裁不了)⇒ 退回工具形态。
// 这不是「静默降级」:Note 里写明为什么给的是描述而不是原图。
return s.toolForm(ctx, path, decision)
}
return s.toolForm(ctx, path, decision)
}
// imageForMain 处理「主模型能收图」这一路:先把整图内联,超预算再裁一块。
func (s *ShotAttacher) imageForMain(ctx context.Context, shot ShotRef, path string) (ShotAttachment, error) {
budget := s.inlineBudget()
if shot.Bytes <= 0 || shot.Bytes <= budget {
src, err := inlineImageCached(ctx, s.resolver.cache, path)
if err != nil {
return ShotAttachment{}, err
}
return ShotAttachment{
Content: model.Content{Type: model.ContentTypeImage, ImageURL: src},
Route: ShotRouteMain,
Note: fmt.Sprintf("[截图] 这一页的视口截图已作为图片附件送出(%s)。", path),
}, nil
}
return s.cropped(ctx, shot, path, budget)
}
// cropped 把超预算的大图裁一块出来内联。
//
// 区域规则(唯一一条,避免「猜哪里重要」):**顶部锚定、整宽**,高度按「预算 / 实际字节」
// 等比缩小(下限 shotCropMinRatio 与 shotCropMinHeight)。理由:
// - 截的是**视口**(`captureBeyondViewport=false`,从 y=0 开始),页面语义上最靠前的
// 内容就在顶部;选「中间/第 N 象限」并不比顶部更不武断,而顶部至少与「视口起点」同源;
// - 缩放不可用(crop 只裁不缩),所以「够不够小」只能靠**少裁一点**来控制;
// - 裁切一定会被**如实标注**:Note 里写明保留了哪一块、完整图在哪、其余区域怎么取。
//
// 尺寸未知(`DimensionsKnown=false` ⇒ Width/Height 为 0)时**不裁**:那种情况下任何区域
// 都是编的。裁完仍超预算同样不硬塞 —— 如实退回工具形态(图仍然被读到了,只是以文字形式)。
func (s *ShotAttacher) cropped(ctx context.Context, shot ShotRef, path string, budget int) (ShotAttachment, error) {
if s.cropper == nil {
return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 没有裁切能力可用", ErrNotConfigured)
}
if shot.Width <= 0 || shot.Height <= 0 {
return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 截图尺寸未知,不猜裁切区域", ErrUnsupportedSource)
}
ratio := float64(budget) / float64(shot.Bytes)
for attempt := 0; attempt < shotCropMaxAttempts; attempt++ {
keep := cropKeepHeight(shot.Height, ratio)
if keep <= 0 {
return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 裁切区域小于下限", ErrUnsupportedSource)
}
uri, err := s.cropper.CropRegion(ctx, path, 0, 0, shot.Width, keep)
if err != nil {
return ShotAttachment{}, err
}
if len(uri) > MaxInlineImageChars {
// 估算偏乐观(PNG 压缩率随内容变化)⇒ 按缺口再缩一次,仍超预算就走工具形态。
ratio *= 0.5
continue
}
return ShotAttachment{
Content: model.Content{Type: model.ContentTypeImage, ImageURL: uri},
Route: ShotRouteMain,
Note: fmt.Sprintf(
"[截图] 这一页的截图(%d×%d,%d 字节)超过单图内联上限,已先裁出**顶部 %d×%d** 送出;"+
"完整图在 %s,其余区域可用 image-crop 按区域自取。",
shot.Width, shot.Height, shot.Bytes, shot.Width, keep, path),
Cropped: true,
CropRegion: fmt.Sprintf("0,0,%d,%d", shot.Width, keep),
}, nil
}
return ShotAttachment{}, fmt.Errorf("vision: shot attach: %w: 裁切后仍超过单图内联上限", ErrImageTooLarge)
}
// cropKeepHeight 按比例算出保留的像素高度,并夹上两条下限(见常量注释)。
// 返回 0 表示「算不出有意义的区域」(调用方据此放弃裁切)。
func cropKeepHeight(height int, ratio float64) int {
if height <= 0 || ratio <= 0 {
return 0
}
keep := int(float64(height) * ratio)
if min := int(float64(height) * shotCropMinRatio); keep < min {
keep = min
}
if keep < shotCropMinHeight {
keep = shotCropMinHeight
}
if keep > height {
keep = height
}
if keep <= 0 {
return 0
}
return keep
}
// toolForm 走工具形态:vision / OCR 工具产出文字,图本身不进主模型上下文。
func (s *ShotAttacher) toolForm(ctx context.Context, path string, decision Decision) (ShotAttachment, error) {
if decision.Kind == KindMain {
// 主模型的图路没走通 ⇒ 用工具形态兜底(有工具的话)。
decision = s.resolver.toolDecision()
}
content, err := s.resolver.resolveToolRoute(ctx, path, shotPrompt, decision)
if err != nil {
return ShotAttachment{}, err
}
route := ShotRouteVisionTool
if decision.Kind == KindOCRTool {
route = ShotRouteOCRTool
}
return ShotAttachment{
Content: content,
Route: route,
Note: fmt.Sprintf(
"[截图] 主模型收不了图(或图超过内联上限且无法裁切),已用 %s 工具读出这一页并附上文字结果;"+
"截图本身在 %s,可按区域用 image-crop 再读。", decision.Tool, path),
}, nil
}
// shotPrompt 是走 vision 工具读**页面截图**时的指令:与用户附图的 describePrompt 不同,
// 这里点明「页面截图」,因为页面上的坐标/表格/按钮文字是这类图最常被追问的东西。
const shotPrompt = "This is a screenshot of a web page. Describe the page content, " +
"including any text, layout, charts or interactive elements that are visible."
// toolDecision 取「工具形态」的路由:主模型那条路不可用时,问一次 Router 要看工具。
// 主模型能收图时 Router 恒返回 KindMain,因此这里把能力显式置为「收不了图」再问一次 ——
// 这是 Router 的既有语义(第一层条件是 SupportsVision),不是新策略。
func (r *MessageResolver) toolDecision() Decision {
if r == nil || r.router == nil {
return Decision{Kind: KindUnsupported, Reason: "no vision router configured"}
}
return r.router.Route(ModelInfo{SupportsVision: false}, RouteRequest{})
}
@@ -0,0 +1,307 @@
package vision
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住 design/28 §7 判据 2 的**视觉侧**(切片 Y4 / B3):一张已落盘的页面截图
// 到底以什么形态进上下文 —— 主模型能收图时是**真的图**(image 部件 + data: URI),
// 收不了图时是**工具形态的文字**,超内联上限时**先裁再送**,每一条都带如实标注。
//
// 用具名假件而不是真浏览器:本文件的被测对象是「图怎么进上下文」,浏览器那一层由
// `internal/capability/browser` 与 `cmd/cyrene/wire_browser*` 的用例负责,两处不混。
// shotPNG 造一张真实的小 PNG(真字节,因为视觉层对图像**字节嗅探**,假字节会被拒)。
func shotPNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x40, A: 0xff})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("编码 PNG: %v", err)
}
return buf.Bytes()
}
// writeShot 把 PNG 落进临时目录并返回与落盘结论同形的 ShotRef。
func writeShot(t *testing.T, data []byte, w, h int) ShotRef {
t.Helper()
path := filepath.Join(t.TempDir(), "shot-test.png")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("写截图: %v", err)
}
return ShotRef{Path: path, Bytes: len(data), Width: w, Height: h, Format: "png"}
}
// fakeCropper 记录裁切区域并返回一段可控长度的 data: URI。
type fakeCropper struct {
calls []string
uri string
failAll bool
}
func (c *fakeCropper) CropRegion(_ context.Context, path string, x, y, w, h int) (string, error) {
c.calls = append(c.calls, fmt.Sprintf("%s|%d,%d,%d,%d", filepath.Base(path), x, y, w, h))
if c.failAll {
return "", errors.New("crop: 假失败")
}
return c.uri, nil
}
// visionInvoker 造一个「记录调用参数并返回固定描述」的工具调用器。
func visionInvoker(t *testing.T, calls *[]string, description string) ToolInvoker {
t.Helper()
return func(_ context.Context, name string, args map[string]any) (any, error) {
*calls = append(*calls, fmt.Sprintf("%s|%v", name, args["image"]))
if name == "ocr" {
return map[string]any{"text": description}, nil
}
return map[string]any{"description": description}, nil
}
}
// newShotFixture 造一个视觉路由夹具。sight 决定主模型能不能收图。
func newShotFixture(t *testing.T, sight bool, cropper ShotCropper, budget int) (*ShotAttacher, *[]string) {
t.Helper()
calls := &[]string{}
rt := NewRouter(WithVisionTool("vision"), WithOCRTool("ocr"))
res := NewMessageResolver(rt, func() ModelInfo {
return ModelInfo{Name: "main", SupportsVision: sight}
}, visionInvoker(t, calls, "页面上写着「渲染后才出现的文字」"))
opts := []ShotAttacherOption{WithShotCropper(cropper)}
if budget > 0 {
opts = append(opts, WithShotInlineBudget(budget))
}
return NewShotAttacher(res, opts...), calls
}
// TestShotAttach_VisionModelGetsRealImage 钉住判据 2 的正面:主模型能收图时,
// 附件是**真的 image 部件**(data: URI),不是一句「图在哪」的路径。
func TestShotAttach_VisionModelGetsRealImage(t *testing.T) {
t.Parallel()
data := shotPNG(t, 8, 8)
shot := writeShot(t, data, 8, 8)
att, calls := newShotFixture(t, true, nil, 0)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteMain {
t.Errorf("Route = %q, want %q", got.Route, ShotRouteMain)
}
if got.Content.Type != model.ContentTypeImage {
t.Fatalf("Content.Type = %q, want image(主模型能收图时必须给真图)", got.Content.Type)
}
if !strings.HasPrefix(got.Content.ImageURL, "data:image/png;base64,") {
t.Errorf("内联结果不是 PNG data: URI:%.60s", got.Content.ImageURL)
}
if got.Note == "" {
t.Error("Note 为空:模型无法据此知道自己看到的是哪张图")
}
if len(*calls) != 0 {
t.Errorf("主模型能收图时不该调用 vision/OCR 工具,却调了 %v", *calls)
}
if got.Cropped {
t.Error("小图不该被裁")
}
}
// TestShotAttach_TextModelGetsToolForm 钉住另一条路:主模型收不了图时走工具形态,
// 图**不进**上下文,进去的是 vision 工具产出的文字。
func TestShotAttach_TextModelGetsToolForm(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 8, 8), 8, 8)
att, calls := newShotFixture(t, false, nil, 0)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteVisionTool {
t.Errorf("Route = %q, want %q", got.Route, ShotRouteVisionTool)
}
if got.Content.Type != model.ContentTypeText {
t.Fatalf("Content.Type = %q, want text(图不该进读不了图的主模型)", got.Content.Type)
}
if !strings.Contains(got.Content.Text, "渲染后才出现的文字") {
t.Errorf("工具产出的描述没进内容:%q", got.Content.Text)
}
if len(*calls) != 1 || !strings.HasPrefix((*calls)[0], "vision|") {
t.Fatalf("工具调用 = %v,want 一次 vision 且带截图路径", *calls)
}
if !strings.Contains((*calls)[0], shot.Path) {
t.Errorf("工具拿到的不是截图路径:%v", *calls)
}
}
// TestShotAttach_OCRToolRoute 钉住 OCR 档(路由器只有 OCR 工具时的路由与字段名不同:
// 读 "text" 而不是 "description")。
func TestShotAttach_OCRToolRoute(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 8, 8), 8, 8)
calls := &[]string{}
res := NewMessageResolver(NewRouter(WithOCRTool("ocr")), func() ModelInfo {
return ModelInfo{SupportsVision: false}
}, visionInvoker(t, calls, "OCR 出来的字"))
att := NewShotAttacher(res)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteOCRTool {
t.Errorf("Route = %q, want %q", got.Route, ShotRouteOCRTool)
}
if !strings.Contains(got.Content.Text, "OCR 出来的字") {
t.Errorf("OCR 结果没进内容:%q", got.Content.Text)
}
}
// TestShotAttach_OversizedShotsAreCroppedFirst 钉住主人点名的「大图先过 crop」:
// 超内联上限的截图先按区域裁一块送进去,且**如实标注**保留了哪一块、完整图在哪。
func TestShotAttach_OversizedShotsAreCroppedFirst(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 40, 40) // 真实字节数远大于 1
cropper := &fakeCropper{uri: "data:image/png;base64,AAAA"}
att, _ := newShotFixture(t, true, cropper, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteMain || got.Content.Type != model.ContentTypeImage {
t.Fatalf("裁切后应仍是给主模型的图:route=%q type=%q", got.Route, got.Content.Type)
}
if !got.Cropped || got.CropRegion == "" {
t.Errorf("裁切事实没被如实标注:Cropped=%v Region=%q", got.Cropped, got.CropRegion)
}
if len(cropper.calls) != 1 {
t.Fatalf("裁切调用 = %v,want 恰好一次", cropper.calls)
}
// 区域规则:顶部锚定、整宽(x=0,y=0),高度夹在 (0, 原图高] 内。
want := fmt.Sprintf("shot-test.png|0,0,40,%d", cropKeepHeight(40, 1.0/float64(shot.Bytes)))
if cropper.calls[0] != want {
t.Errorf("裁切区域 = %q, want %q", cropper.calls[0], want)
}
for _, kw := range []string{shot.Path, "image-crop", "40×40"} {
if !strings.Contains(got.Note, kw) {
t.Errorf("标注里缺 %q:%q", kw, got.Note)
}
}
}
// TestShotAttach_OversizedWithoutCropperFallsBackToToolForm 钉住降级方向:
// 没有裁切能力时**不硬塞**(也不假装成功),如实改走工具形态,并在标注里说明原因。
func TestShotAttach_OversizedWithoutCropperFallsBackToToolForm(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 40, 40)
att, calls := newShotFixture(t, true, nil, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteVisionTool || got.Content.Type != model.ContentTypeText {
t.Fatalf("没有裁切能力时应走工具形态:route=%q type=%q", got.Route, got.Content.Type)
}
if len(*calls) != 1 {
t.Fatalf("工具调用 = %v,want 一次", *calls)
}
if !strings.Contains(got.Note, "无法裁切") && !strings.Contains(got.Note, "内联上限") {
t.Errorf("降级原因没写在标注里:%q", got.Note)
}
}
// TestShotAttach_CropStillTooLargeFallsBack 钉住「裁完还超限」这一档:不硬塞半张坏图,
// 降级到工具形态(图仍然被读到了,只是以文字形式)。
func TestShotAttach_CropStillTooLargeFallsBack(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 40, 40)
cropper := &fakeCropper{uri: "data:image/png;base64," + strings.Repeat("A", MaxInlineImageChars)}
att, _ := newShotFixture(t, true, cropper, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if got.Route != ShotRouteVisionTool {
t.Errorf("裁完仍超限时应降级到工具形态:%q", got.Route)
}
if len(cropper.calls) > shotCropMaxAttempts {
t.Errorf("裁切次数 %d 超过上限 %d", len(cropper.calls), shotCropMaxAttempts)
}
}
// TestShotAttach_CropSkippedWhenDimensionsUnknown 钉住「不猜区域」:字节超限但尺寸未知时
// **一次都不裁**(任何区域都会是编出来的),直接走工具形态。
func TestShotAttach_CropSkippedWhenDimensionsUnknown(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 40, 40), 0, 0)
cropper := &fakeCropper{uri: "data:image/png;base64,AAAA"}
att, _ := newShotFixture(t, true, cropper, 1)
got, err := att.Attach(context.Background(), shot)
if err != nil {
t.Fatalf("Attach: %v", err)
}
if len(cropper.calls) != 0 {
t.Errorf("尺寸未知时不该裁切,却调了 %v", cropper.calls)
}
if got.Route != ShotRouteVisionTool {
t.Errorf("Route = %q, want 工具形态", got.Route)
}
}
// TestShotAttach_NoToolNoRouteIsHonest 钉住最坏一档:主模型读不了图、也没绑任何工具时
// **如实报错**(绝不返回一个「成功但没有图」的附件 —— 那会让模型以为页面里什么都没有)。
func TestShotAttach_NoToolNoRouteIsHonest(t *testing.T) {
t.Parallel()
shot := writeShot(t, shotPNG(t, 8, 8), 8, 8)
res := NewMessageResolver(NewRouter(), func() ModelInfo {
return ModelInfo{SupportsVision: false}
}, nil)
att := NewShotAttacher(res)
_, err := att.Attach(context.Background(), shot)
if !errors.Is(err, ErrUnsupportedSource) {
t.Fatalf("err = %v, want %v", err, ErrUnsupportedSource)
}
}
// TestShotAttach_GuardsAndMissingFile 钉住两条入口守卫与一条真实失败:
// nil resolver ⇒ ErrNotConfigured;空路径 ⇒ 报错;路径不存在 ⇒ 内联失败后**不假装**成功。
func TestShotAttach_GuardsAndMissingFile(t *testing.T) {
t.Parallel()
if _, err := NewShotAttacher(nil).Attach(context.Background(), ShotRef{Path: "x.png"}); !errors.Is(err, ErrNotConfigured) {
t.Errorf("nil resolver 的 err = %v, want %v", err, ErrNotConfigured)
}
att, _ := newShotFixture(t, true, nil, 0)
if _, err := att.Attach(context.Background(), ShotRef{Path: " "}); err == nil {
t.Error("空路径应当报错")
}
missing := ShotRef{Path: filepath.Join(t.TempDir(), "not-there.png"), Bytes: 10, Width: 8, Height: 8}
got, err := att.Attach(context.Background(), missing)
if err == nil && got.Content.Type == model.ContentTypeImage {
t.Error("文件不存在时不该交出一个 image 部件")
}
if err == nil && got.Route == ShotRouteMain {
t.Error("文件不存在时不该报 Route=main")
}
}
+170
View File
@@ -0,0 +1,170 @@
package browser
import (
"context"
"strings"
"sync"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件是 design/28 §7 判据 2(M4 闭环)的**浏览器侧**:截图一落盘就把它挂进
// 「**本回合待送出的截图**」附件箱,让循环的装配钩子在同一回合的下一次模型调用里取走
// (钩子在 `cmd/cyrene/wire_browser_context.go`,取走时按主模型能力决定给真图还是
// 工具形态的文字 —— 判定与内联都在 `internal/runtime/multimodal/vision`)。
//
// 为什么要一个「箱」而不是让工具自己把图塞进模型请求:工具拿到的是**一次调用**的
// context,它能改的只有自己的返回值(工具结果是**文本**,见 `internal/runtime/loop/tools.go`
// 的 `toolResultMessage`),而「挂一张图到本轮视图」是**循环**的职责(消息归属、
// 角色合法性、checkpoint 一致性都在那边)。两边各让半步:工具交图,循环把它变成附件。
//
// 三条口径:
//
// 1. **按会话隔离**:箱子按 `loop.SessionFromContext(ctx)` 的会话 id 分桶 —— 真并行
// 多路时,A 会话拍的图绝不会跑到 B 会话的上下文里(跨端串话是本项目踩过的坑);
// 拿不到会话 id(子代理 / 未接编排器的调用)时**不入箱**,如实报
// `turnAttachment=no-session`,由模型自己按路径去读;
// 2. **有界 + 有寿命**:每个会话最多 `maxPendingShots` 张、`shotOutboxTTL` 内有效。
// 截图是**要送出去**的东西,不是缓存:攒着不送只会把上下文撑大(一张 5 MiB 的图
// 进 base64 后约 7 M 字符)。超上限时丢**最旧**的那张并如实计数(`Dropped`);
// 3. **取走即消费**:`Drain` 把该会话的待送图整体取走并清空(循环每步取一次),
// 因此同一张图不会被送到两次(重复送图既费预算又会让模型以为「又拍了一张」)。
type ShotOutbox struct {
mu sync.Mutex
bySess map[string][]pendingShot
dropped map[string]int
now func() time.Time
}
// pendingShot 是箱里的一格:截图 + 入箱时间(TTL 判据)。
type pendingShot struct {
shot browsercap.Shot
at time.Time
}
// 箱子的具名上限。
const (
// maxPendingShots 是**每个会话**最多攒着的截张数。一次任务里连拍 4 张还没送出去
// 说明装配钩子没在跑(或模型在狂拍),此时丢最旧的一张比把 4 张 5 MiB 的图
// 一起塞进下一个请求更负责。
maxPendingShots = 4
// shotOutboxTTL 是待送截图的有效期。超过它的图不再送出(会话可能已经结束),
// 文件仍在盘上,模型按路径照样能读。
shotOutboxTTL = 10 * time.Minute
)
// 工具结果里 `turnAttachment` 的取值(闭集):它让**模型与面板**都能看出这张图到底
// 有没有进上下文,而不是只在日志里。
const (
// TurnAttachmentQueued 报告截图已入箱,将在本回合的下一次模型调用里作为附件送出。
TurnAttachmentQueued = "queued"
// TurnAttachmentNoSession 报告本次调用拿不到会话 id(子代理 / 未接编排器),未入箱。
TurnAttachmentNoSession = "no-session"
// TurnAttachmentFull 报告箱已满,最旧的一张被丢弃(如实计数,不静默)。
TurnAttachmentFull = "outbox-full"
// TurnAttachmentDisabled 报告本工具没有附件箱(`New(nil)` 这类夹具),未入箱。
TurnAttachmentDisabled = "disabled"
)
// NewShotOutbox 造一个空箱子。零值不可用(map 未初始化),一律经本函数构造 ——
// 与 `New` 里默认建箱是同一个实现,不存在第二份。
func NewShotOutbox() *ShotOutbox {
return &ShotOutbox{
bySess: make(map[string][]pendingShot),
dropped: make(map[string]int),
now: time.Now,
}
}
// Put 把一张截图放进 sessionID 的格子。返回本次入箱的结论(TurnAttachment*)。
// sessionID 为空 ⇒ 谁也不放(调用方据此如实报 no-session)。
func (o *ShotOutbox) Put(sessionID string, shot browsercap.Shot) string {
if o == nil {
return TurnAttachmentDisabled
}
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return TurnAttachmentNoSession
}
o.mu.Lock()
defer o.mu.Unlock()
o.expireLocked(sessionID)
list := append(o.bySess[sessionID], pendingShot{shot: shot, at: o.now()})
status := TurnAttachmentQueued
if len(list) > maxPendingShots {
// 丢最旧的:新的那张更接近「模型现在想问的那一页」。
list = list[len(list)-maxPendingShots:]
o.dropped[sessionID]++
status = TurnAttachmentFull
}
o.bySess[sessionID] = list
return status
}
// Drain 取走并清空 sessionID 的待送截图(取走即消费)。会话 id 为空或没有待送图时
// 返回 nil —— 那是常态(工具批次之间通常没有新截图),不是错误。
func (o *ShotOutbox) Drain(sessionID string) []browsercap.Shot {
if o == nil {
return nil
}
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil
}
o.mu.Lock()
defer o.mu.Unlock()
o.expireLocked(sessionID)
list := o.bySess[sessionID]
delete(o.bySess, sessionID)
delete(o.dropped, sessionID)
if len(list) == 0 {
return nil
}
out := make([]browsercap.Shot, 0, len(list))
for _, p := range list {
out = append(out, p.shot)
}
return out
}
// Pending 报告某会话当前攒着几张待送截图(面板/诊断用;不消费)。
func (o *ShotOutbox) Pending(sessionID string) int {
if o == nil {
return 0
}
o.mu.Lock()
defer o.mu.Unlock()
o.expireLocked(sessionID)
return len(o.bySess[sessionID])
}
// expireLocked 丢掉该会话里过期的格子(TTL 之外)。调用方必须持有 o.mu。
func (o *ShotOutbox) expireLocked(sessionID string) {
list := o.bySess[sessionID]
if len(list) == 0 {
return
}
cutoff := o.now().Add(-shotOutboxTTL)
kept := list[:0]
for _, p := range list {
if p.at.After(cutoff) {
kept = append(kept, p)
}
}
if len(kept) == 0 {
delete(o.bySess, sessionID)
return
}
o.bySess[sessionID] = kept
}
// PutForContext 按 ctx 上的会话 id 入箱(工具在自己的 Execute 里用这一个入口:
// 会话 id 的来源只有 `loop.SessionFromContext` 一处,绝不从参数或模型输出推断)。
func (o *ShotOutbox) PutForContext(ctx context.Context, shot browsercap.Shot) string {
if o == nil {
return TurnAttachmentDisabled
}
return o.Put(loop.SessionFromContext(ctx), shot)
}
+171
View File
@@ -0,0 +1,171 @@
package browser
// 本文件钉住 B3 闭环的**浏览器侧**(design/28 §7 判据 2、切片 Y4):截图落盘后被放进
// 「本回合待送出」的附件箱,且**按会话隔离、取走即消费、有界**;没有会话 id 时如实报
// no-session 而不是丢进一个谁都能拿的公共格子。
//
// 视觉侧(图怎么变成 image 部件 / vision 工具文字)由
// `internal/runtime/multimodal/vision/shot_test.go` 覆盖,两处不重复。
import (
"context"
"strings"
"testing"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
)
// fakeShot 造一张与落盘结论同形的截图(本文件不碰真浏览器)。
func fakeShot(path string) browsercap.Shot {
return browsercap.Shot{Path: path, Bytes: 1024, SHA256: "abc", Format: "png",
Width: 1280, Height: 800, DimensionsKnown: true}
}
// TestShotOutbox_PerSessionAndDrainOnce 钉住「按会话隔离 + 取走即消费」:
// A 会话的截图绝不出现在 B 会话的取用结果里,且同一张只能被送出一次。
func TestShotOutbox_PerSessionAndDrainOnce(t *testing.T) {
t.Parallel()
box := NewShotOutbox()
if got := box.Put("sess-a", fakeShot("/a.png")); got != TurnAttachmentQueued {
t.Fatalf("Put = %q, want %q", got, TurnAttachmentQueued)
}
if n := box.Pending("sess-b"); n != 0 {
t.Fatalf("B 会话不该看到 A 的截图,Pending=%d", n)
}
if got := box.Drain("sess-b"); got != nil {
t.Fatalf("B 会话 Drain = %v, want nil", got)
}
drained := box.Drain("sess-a")
if len(drained) != 1 || drained[0].Path != "/a.png" {
t.Fatalf("Drain = %+v", drained)
}
if again := box.Drain("sess-a"); again != nil {
t.Fatalf("第二次 Drain 应当为空(取走即消费):%+v", again)
}
}
// TestShotOutbox_NoSessionIsHonest 钉住「拿不到会话 id 就不入箱」:子代理 / 未接编排器的
// 调用必须如实报 no-session(图仍在盘上,模型按路径自己读),而不是丢进一个全局格子。
//
// ⚠️ 这里只测「没有会话」这一档:把会话 id 播种到 ctx 上的**唯一**生产者是
// `loop.Run`(`withLoopSession` 不导出),因此「有会话时真的入箱」由组合根的真实循环
// 用例覆盖(`cmd/cyrene/wire_browser_context_test.go`),本包不伪造那条通道。
func TestShotOutbox_NoSessionIsHonest(t *testing.T) {
t.Parallel()
box := NewShotOutbox()
if got := box.Put("", fakeShot("/x.png")); got != TurnAttachmentNoSession {
t.Fatalf("空会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession)
}
if got := box.Put(" ", fakeShot("/x.png")); got != TurnAttachmentNoSession {
t.Fatalf("空白会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession)
}
if got := box.PutForContext(context.Background(), fakeShot("/x.png")); got != TurnAttachmentNoSession {
t.Fatalf("ctx 上没有会话时的 PutForContext = %q, want %q", got, TurnAttachmentNoSession)
}
if n := box.Pending(""); n != 0 {
t.Fatalf("空会话不该攒下任何东西:Pending=%d", n)
}
}
// TestShotOutbox_BoundedAndTTL 钉住有界与寿命:超过上限丢**最旧**的一张并如实报
// outbox-full;超过 TTL 的截图不再送出(会话可能早已结束)。
func TestShotOutbox_BoundedAndTTL(t *testing.T) {
t.Parallel()
box := NewShotOutbox()
base := time.Now()
box.now = func() time.Time { return base }
for i := 0; i < maxPendingShots; i++ {
if got := box.Put("s", fakeShot(string(rune('a'+i)))); got != TurnAttachmentQueued {
t.Fatalf("第 %d 张 Put = %q", i, got)
}
}
if got := box.Put("s", fakeShot("overflow")); got != TurnAttachmentFull {
t.Fatalf("超上限的 Put = %q, want %q", got, TurnAttachmentFull)
}
got := box.Drain("s")
if len(got) != maxPendingShots {
t.Fatalf("攒着的张数 = %d, want %d", len(got), maxPendingShots)
}
if got[len(got)-1].Path != "overflow" {
t.Errorf("最新的一张应当留下:%+v", got)
}
// TTL:过期的格子被清掉(文件仍在盘上,只是不再送出)。
box.Put("t", fakeShot("/old.png"))
box.now = func() time.Time { return base.Add(shotOutboxTTL + time.Minute) }
if n := box.Pending("t"); n != 0 {
t.Errorf("过期截图仍在箱里:Pending=%d", n)
}
}
// TestTool_ScreenshotQueuesShotForTheTurn 钉住工具面的接线:带会话 ctx 的截图动作把图
// 放进箱子并如实报 queued,同时**仍然**返回路径与统计(模型/面板都能按路径再读)。
//
// 会话来自 `loop.Run`(本包造不出来),因此这里经**箱子本身**验证注入点:工具用的是
// 同一个箱子实例(`WithShotOutbox` 注入 → `ShotOutbox()` 取回),真实循环链路由组合根
// 用例端到端覆盖。
func TestTool_ScreenshotQueuesShotForTheTurn(t *testing.T) {
t.Parallel()
drv := &fakeDriver{available: true, shot: fakeShot("/shots/s1.png")}
injected := NewShotOutbox()
injected.Put("sess-shot", fakeShot("/preset.png"))
tool := New(drv, WithShotOutbox(injected))
if tool.ShotOutbox() != injected {
t.Fatal("WithShotOutbox 注入的箱子没被采用:装配钩子将取不到截图")
}
// 工具在**没有会话**的 ctx 下动作:图仍落盘,附件状态如实报 no-session。
res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m, ok := res.(map[string]any)
if !ok {
t.Fatalf("结果类型 = %T", res)
}
if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession {
t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession)
}
if got, _ := m["path"].(string); got != "/shots/s1.png" {
t.Fatalf("path = %q", got)
}
if hint, _ := m["hint"].(string); hint == "" {
t.Error("hint 为空:模型不知道这张图进没进上下文")
}
// 预先放进箱子的那张仍在(本次调用没有会话,因此没有消费它)。
if got := tool.ShotOutbox().Drain("sess-shot"); len(got) != 1 || got[0].Path != "/preset.png" {
t.Fatalf("箱子里的截图 = %+v", got)
}
}
// TestTool_ScreenshotWithoutSessionIsHonest 钉住没有会话时的行为:动作照样成功(图落盘),
// 但 `turnAttachment` 如实报 no-session,且提示文案改为「按路径自己读」。
func TestTool_ScreenshotWithoutSessionIsHonest(t *testing.T) {
t.Parallel()
drv := &fakeDriver{available: true, shot: fakeShot("/shots/s2.png")}
tool := New(drv)
res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := res.(map[string]any)
if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession {
t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession)
}
if hint, _ := m["hint"].(string); hint == "" || !strings.Contains(hint, "NOT attached") {
t.Errorf("没有会话时的提示必须说清「没进上下文」:%q", hint)
}
}
// TestTool_StateExposesFactsWithoutDriver 钉住面板取数的兜底:驱动不支持事实面
// (假驱动)时返回零值快照,而不是 panic —— 面板据此显示「未知」。
func TestTool_StateExposesFactsWithoutDriver(t *testing.T) {
t.Parallel()
if got := New(nil).State(); got.Available {
t.Errorf("nil 驱动的事实面 = %+v,应当是零值", got)
}
if got := (*Tool)(nil).State(); got.Available {
t.Errorf("nil 工具的事实面 = %+v", got)
}
}
+258
View File
@@ -0,0 +1,258 @@
package browser
// 本文件是 `browser` 插件向**面板**声明的管理页面(design/09 §5.2 的 `adminPanels`,
// design/28 §8 B4 的落点):形状即 `pkg/plugins/sdk` 的 `Panel`。
//
// 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它
// - 不注入任何 JS/CSS/HTML、
// - 不给 URL(只有动作名,走宿主的通用动作端点分发)、
// - 不开任何路由。
// 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、
// 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。
//
// 页面构成(一处读全):
//
// ① form 浏览器配置 —— 有头/无头、视口、profile 目录、并发上限…(保存即热重载)
// ② table 运行状态 —— 只读事实面 + 行内动作「拍一张(当前页面)」
// ③ table 指纹自检 —— 只读事实面 + 行内动作「重新自检」(唯一会起浏览器的读面动作)
// ④ table 最近截图 —— 只统计(字节/尺寸/格式),不含路径与内容
//
// 为什么状态/自检/截图都做成 **table** 而不是 `status`:`status` 区块按 §5.2.1 的接入点表
// **不消费 `source`**(`paneldecl.shapeOf` 明写 notAllowed),没有来源就没有活数据 —— 硬要
// 用 status 只能给一份声明期的静态文本,那才是真正的「看起来有、其实没有」。表格是
// 声明面里唯一既有来源又能带按钮的形状,因此三块活数据都用它(同为一处口径)。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**:
// `$DATA_DIR/config/plugins/browser.json`(design/13 §2.1:文件名 = 插件 id),
// 两边由 panel_test.go 钉住。
const PanelPluginID = "browser"
// PanelIDSettings 是「浏览器与截图」页的面板 id。
// 前端路由为 `/admin/plugin/browser/settings`(design/09 §5.7 C)。
const PanelIDSettings = "settings"
// 本插件声明的**全部**动作名(design/09 §5.2.1 的动作面)。
//
// 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403)。
// 把「声明」与「动作名」放同一个文件,是为了让「表里写了一个没实现的动作」这种半渲染
// 在 review 时一眼可见 —— 它们就挨着。
const (
// ActionState 是运行状态只读来源(表格数据源)。**不启动浏览器**。
ActionState = "plugin.getBrowserState"
// ActionShots 是最近截图只读来源(表格数据源)。**不读文件、不含内容**。
ActionShots = "plugin.listShots"
// ActionFingerprint 是最后一次指纹自检的只读来源(表格数据源),未自检过时如实说「未自检」。
ActionFingerprint = "plugin.getFingerprint"
// ActionSaveConfig 保存浏览器配置(表单提交)。落 `$DATA_DIR/config/plugins/browser.json`
// 并**热重载**工具(design/09 §5.5 的口径:面板的意义就是不改 `.env`、不重启)。
ActionSaveConfig = "plugin.saveBrowserConfig"
// ActionShoot 拍一张(行内动作)。owner-only + 落审计(design/28 §1 M3)。
ActionShoot = "plugin.shootPage"
// ActionCheck 重新做一次指纹自检(行内动作)。它会**启动浏览器** —— 这是本页唯一有
// 进程外副作用的动作,因此是显式按钮而绝非「打开页面就顺手跑」。
ActionCheck = "plugin.checkFingerprint"
)
// ActionNames 返回本插件声明的动作名(顺序与 AdminPanels 里出现的顺序一致:写表单 →
// 三个读来源 → 两个行内动作)。调用方(插件管理器的动作端点)据此建白名单:
// 白名单外的动作名一律拒,绝不「先执行再看结果」。
func ActionNames() []string {
return []string{
ActionSaveConfig,
ActionState, ActionFingerprint, ActionShots,
ActionShoot, ActionCheck,
}
}
// 表里三块只读数据的列 key(与 paneldata.go 的行字段同源:声明一个 key、行里写另一个,
// 表现就是「表头在、数据空」—— 所以两边只允许引用常量)。
const (
// 运行状态列
colSource = "source"
colAvailable = "available"
colHeadless = "headless"
colViewport = "viewport"
colProfileDir = "profileDir"
colConcurrent = "concurrency"
colIdleReco = "idleTimeoutSeconds"
colShotCap = "shotMaxBytes"
// 指纹自检列
colWebdriver = "webdriver"
colScreen = "screen"
colTimezone = "timezone"
colLanguage = "language"
colUserAgent = "userAgent"
colCheckedAt = "checkedAt"
// 最近截图列
colShotAt = "at"
colShotBytes = "bytes"
colShotSize = "size"
colShotFormat = "format"
colShotDegraded = "degraded"
)
// 表单字段 key(与 `browsercfg.Config` 的 JSON tag 逐字一致 —— 字段名与文件字段名对上,
// 面板写的键就是运行期读的键)。
const (
FieldHeadless = "headless"
FieldExecutable = "executable"
FieldCDPEndpoint = "cdpEndpoint"
FieldProfileDir = "profileDir"
FieldViewportWidth = "viewportWidth"
FieldViewportHeight = "viewportHeight"
FieldIdleTimeout = "idleTimeoutSeconds"
FieldConcurrency = "concurrency"
)
// PanelInput 是声明生成需要的**当前事实**:配置(给表单默认值)与可用性(决定描述文案
// 是否如实说明「这台机器现在没有浏览器」)。
//
// 为什么默认值要由调用方传入而不是读文件:声明是**装配期**生成的(组合根一处),
// 读文件是它唯一的事实来源;把「读哪份配置」的判断塞进声明函数会让「面板显示的」与
// 「工具真的用的」两处各自算一次。
type PanelInput struct {
// Config 是**当前生效**的配置(已 Normalize)。
Config browsercfg.Config
// Available 报告当前有没有可用来源。
Available bool
// Source 是来源描述(本机 / 容器端点),只进描述文案。
Source string
}
// AdminPanels 返回本插件声明的管理页面。
func AdminPanels(in PanelInput) []sdk.Panel {
return []sdk.Panel{{
ID: PanelIDSettings,
Title: "浏览器与截图",
Description: panelDescription(in),
Group: sdk.GroupPlugins,
Order: 30,
Icon: "browser",
// 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。browser 是编进二进制的
// 内置能力,没有卸载路径 ⇒ 恒为启用;写条件是为了让「启用事实」由插件管理器给出。
When: &sdk.When{Enabled: boolFlag(true)},
Sections: []sdk.Section{
configSection(in.Config),
stateSection(),
fingerprintSection(),
shotsSection(),
},
}}
}
// panelDescription 拼页面描述。不可用时**如实说明**(照 web-search `off` 的诚实口径:
// 面板可以配,配好保存后热重载生效;没有来源时工具不会注册)。
func panelDescription(in PanelInput) string {
base := "配置这个浏览器(有头/无头、视口、profile 目录、并发上限),查看运行状态与指纹自检," +
"并拍一张当前页面。改动落 $DATA_DIR/config/plugins/browser.json,**保存后立即生效**" +
"(工具会按新配置重建,无需重启)。"
if in.Available {
return base + "当前来源:" + in.Source + "。"
}
return base + "⚠️ 当前**没有**可用来源(未探测到本机 Chrome/Chromium,也没有在跑的 CDP 端点):" +
"`browser` 工具此刻**不在**模型的能力面里;在这里配好路径/端点并保存,重建后即生效。"
}
// configSection 是配置表单:字段与 `browsercfg.Config` 同源,默认值取当前生效值
// (面板因此「打开即显示现状」)。
func configSection(cfg browsercfg.Config) sdk.Section {
one := 1.0
return sdk.Section{
Type: sdk.SectionForm,
ID: "config",
Title: "浏览器配置",
Fields: []sdk.Field{
{Key: FieldHeadless, Label: "无头模式", Type: sdk.FieldSwitch, Default: cfg.Headless,
Placeholder: "关 = 有头(默认;过风控的主路径)"},
{Key: FieldViewportWidth, Label: "视口宽", Type: sdk.FieldNumber, Default: cfg.ViewportWidth,
Min: floatPtr(320), Max: floatPtr(float64(browsercfg.MaxViewportWidth))},
{Key: FieldViewportHeight, Label: "视口高", Type: sdk.FieldNumber, Default: cfg.ViewportHeight,
Min: floatPtr(240), Max: floatPtr(float64(browsercfg.MaxViewportHeight))},
{Key: FieldProfileDir, Label: "profile 目录", Type: sdk.FieldText, Default: cfg.ProfileDir,
Placeholder: "含 cookie/登录态;默认 $DATA_DIR/browser/profile"},
{Key: FieldConcurrency, Label: "并发上限", Type: sdk.FieldNumber, Default: cfg.Concurrency,
Min: &one, Max: &one,
Placeholder: "固定 1:同一 profile 起两个实例会互锁"},
{Key: FieldIdleTimeout, Label: "空闲回收(秒)", Type: sdk.FieldNumber, Default: cfg.IdleTimeoutSeconds,
Min: floatPtr(1)},
{Key: FieldExecutable, Label: "浏览器可执行文件", Type: sdk.FieldText, Default: cfg.Executable,
Placeholder: "留空 = 自动探测本机 Chrome/Chromium"},
{Key: FieldCDPEndpoint, Label: "CDP 端点", Type: sdk.FieldText, Default: cfg.CDPEndpoint,
Placeholder: "如 http://127.0.0.1:9222(显式给出时取代自动探测)"},
},
Submit: &sdk.Submit{Action: ActionSaveConfig},
}
}
// stateSection 是「运行状态」:只读事实面 + 行内动作「拍一张」。
func stateSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "state",
Title: "运行状态",
Source: &sdk.Source{Action: ActionState},
Columns: []sdk.Column{
{Key: colSource, Label: "来源"},
{Key: colAvailable, Label: "可用"},
{Key: colHeadless, Label: "无头"},
{Key: colViewport, Label: "视口"},
{Key: colProfileDir, Label: "profile"},
{Key: colConcurrent, Label: "并发"},
{Key: colIdleReco, Label: "空闲回收(秒)"},
{Key: colShotCap, Label: "截图上限(字节)"},
},
RowActions: []sdk.RowAction{
{ID: "shoot", Label: "拍一张(当前页面)", Action: ActionShoot},
},
}
}
// fingerprintSection 是「指纹自检」:默认只显示**上一次**读数(不启动浏览器),
// 要刷新得点行内动作(那是显式的、有进程外副作用的动作)。
func fingerprintSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "fingerprint",
Title: "指纹自检",
Source: &sdk.Source{Action: ActionFingerprint},
Columns: []sdk.Column{
{Key: colWebdriver, Label: "webdriver"},
{Key: colScreen, Label: "屏幕"},
{Key: colTimezone, Label: "时区"},
{Key: colLanguage, Label: "语言"},
{Key: colUserAgent, Label: "User-Agent"},
{Key: colCheckedAt, Label: "自检时间"},
},
RowActions: []sdk.RowAction{
{ID: "check", Label: "重新自检(会启动浏览器)", Action: ActionCheck},
},
}
}
// shotsSection 是「最近截图」:只有统计(时间/字节/尺寸/格式),**不含路径与内容**。
func shotsSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "shots",
Title: "最近截图",
Source: &sdk.Source{Action: ActionShots},
Columns: []sdk.Column{
{Key: colShotAt, Label: "时间"},
{Key: colShotBytes, Label: "字节"},
{Key: colShotSize, Label: "尺寸"},
{Key: colShotFormat, Label: "格式"},
{Key: colShotDegraded, Label: "降级"},
},
}
}
// boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。
func boolFlag(v bool) *bool { return &v }
// floatPtr 是 `&1.0` 的简写(sdk.Field.Min/Max 是指针,缺省与「显式 0」不同义)。
func floatPtr(v float64) *float64 { return &v }
+306
View File
@@ -0,0 +1,306 @@
package browser
// 本文件钉住 B4 面板的**声明面与数据面**(design/09 §5.2/§5.2.1、design/28 §8 B4):
// 声明能过宿主闭集校验、动作名与声明同源、行 key 与列 key 同源、只读区块里没有
// 任何会让 viewer 看到浏览内容的字段。
//
// 「动作真能分发 + 读写面分类正确」用真实面板中枢验证,落在组合根
// (`cmd/cyrene/wire_browser_panel_test.go`):那两件事的判定者不是本包。
import (
"path/filepath"
"strings"
"testing"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// testPanelInput 造一份声明入参(配置取默认值 + 给定可用性)。
func testPanelInput(available bool) PanelInput {
return PanelInput{
Config: browsercfg.Defaults(`C:\data`),
Available: available,
Source: "本机 Chrome(有头)",
}
}
// TestAdminPanelsPassHostValidation 钉住声明能过宿主闭集校验(形状错 ⇒ 整个面板被拒,
// 那是最难排查的一种失败:面板「就是不出现」,而日志里才有原因)。
func TestAdminPanelsPassHostValidation(t *testing.T) {
t.Parallel()
if err := paneldecl.Validate(PanelPluginID, AdminPanels(testPanelInput(true))); err != nil {
t.Fatalf("宿主校验不通过(面板会被整体拒掉): %v", err)
}
if err := paneldecl.Validate(PanelPluginID, AdminPanels(testPanelInput(false))); err != nil {
t.Fatalf("不可用档的声明也必须过校验: %v", err)
}
}
// TestPanelPluginIDMatchesConfigFileName 钉住「文件名 = 插件 id」(design/13 §2.1):
// 两个口径一旦分叉,面板写的配置与工具读的配置就不是同一份。
func TestPanelPluginIDMatchesConfigFileName(t *testing.T) {
t.Parallel()
path := browsercfg.DefaultConfigPath(`C:\data`)
base := filepath.Base(path)
if base != PanelPluginID+".json" {
t.Fatalf("配置文件名 %q 与插件 id %q 不同源", base, PanelPluginID)
}
if dir := filepath.Base(filepath.Dir(path)); dir != "plugins" {
t.Fatalf("配置落点目录 = %q, want plugins", dir)
}
}
// TestAdminPanelsActionsAreDeclaredOnce 钉住「声明里的动作名 ⊆ ActionNames()」且无重复:
// 否则「声明了却没实现」或「白名单漏一个」都会变成前端点了没反应的半渲染。
func TestAdminPanelsActionsAreDeclaredOnce(t *testing.T) {
t.Parallel()
declared := make(map[string]bool)
for _, n := range ActionNames() {
if declared[n] {
t.Fatalf("ActionNames() 里 %q 重复", n)
}
declared[n] = true
}
used := make([]string, 0, len(declared))
for _, p := range AdminPanels(testPanelInput(true)) {
for _, s := range p.Sections {
if s.Submit != nil {
used = append(used, s.Submit.Action)
}
if s.Source != nil {
used = append(used, s.Source.Action)
}
for _, a := range s.RowActions {
used = append(used, a.Action)
}
}
}
if len(used) != len(declared) {
t.Fatalf("声明用了 %d 个动作,ActionNames() 给了 %d 个:%v", len(used), len(declared), used)
}
for _, name := range used {
if !declared[name] {
t.Errorf("声明里的动作 %q 不在 ActionNames() 里", name)
}
}
for _, name := range ActionNames() {
if strings.ContainsAny(name, "/:") {
t.Errorf("动作名 %q 含 URL 成分(§5.4.4:只有动作名,没有 URL)", name)
}
}
}
// TestActionNamesAreTheContract 钉住六个动作名的**字面量**:它们是前端与动作端点之间的
// 契约(前端按声明里的名字调 `POST /admin/plugins/{id}/actions/{action}`),改名会让面板上
// 的按钮静默失效。因此这里写死期望值 —— 改名字必须同时改这里(一个显式的动作)。
func TestActionNamesAreTheContract(t *testing.T) {
t.Parallel()
want := []string{
"plugin.saveBrowserConfig",
"plugin.getBrowserState",
"plugin.getFingerprint",
"plugin.listShots",
"plugin.shootPage",
"plugin.checkFingerprint",
}
got := ActionNames()
if len(got) != len(want) {
t.Fatalf("动作数 = %d, want %d:%v", len(got), len(want), got)
}
for i, name := range want {
if got[i] != name {
t.Errorf("ActionNames()[%d] = %q, want %q", i, got[i], name)
}
}
}
// TestAdminPanelsCarryNoSecretsAndNoURLs 钉住两条红线:声明里没有 `secret` 字段
// (本插件没有凭据面,多一个都说明有人把凭据塞进了面板声明),也没有任何 URL 文本
// (防线:声明里塞不进请求面)。
func TestAdminPanelsCarryNoSecretsAndNoURLs(t *testing.T) {
t.Parallel()
for _, p := range AdminPanels(testPanelInput(true)) {
for _, s := range p.Sections {
for _, f := range s.Fields {
if f.Type == sdk.FieldSecret {
t.Errorf("字段 %q 是 secret:browser 插件的配置里没有凭据面", f.Key)
}
if strings.Contains(f.Placeholder, "http") && f.Key != FieldCDPEndpoint {
t.Errorf("字段 %q 的提示里带 URL(%q)", f.Key, f.Placeholder)
}
}
if s.Source != nil && strings.Contains(s.Source.Action, "://") {
t.Errorf("source 里有 URL: %q", s.Source.Action)
}
}
}
}
// TestConfigSectionDefaultsMirrorCurrentConfig 钉住「打开即显示现状」:表单字段的默认值
// 就是当前生效配置(面板因此不必自己读文件 —— 读的是同一份事实)。
func TestConfigSectionDefaultsMirrorCurrentConfig(t *testing.T) {
t.Parallel()
cfg := browsercfg.Config{
Headless: true,
Executable: `C:\chrome.exe`,
CDPEndpoint: "http://127.0.0.1:9222",
ProfileDir: `C:\data\browser\profile-x`,
ViewportWidth: 1600,
ViewportHeight: 900,
IdleTimeoutSeconds: 120,
Concurrency: 1,
}
cfg = cfg.Normalize(`C:\data`)
want := map[string]any{
FieldHeadless: cfg.Headless,
FieldExecutable: cfg.Executable,
FieldCDPEndpoint: cfg.CDPEndpoint,
FieldProfileDir: cfg.ProfileDir,
FieldViewportWidth: cfg.ViewportWidth,
FieldViewportHeight: cfg.ViewportHeight,
FieldIdleTimeout: cfg.IdleTimeoutSeconds,
FieldConcurrency: cfg.Concurrency,
}
got := map[string]any{}
for _, s := range configSection(cfg).Fields {
got[s.Key] = s.Default
}
for key, v := range want {
if got[key] != v {
t.Errorf("字段 %s 默认值 = %v, want %v", key, got[key], v)
}
}
}
// TestPanelUnavailableDescriptionIsHonest 钉住「没有来源时说明白」:面板描述必须点出
// 工具**此刻不在**模型能力面里(照 web-search `off` 的诚实口径),而不是含糊地说「可能不可用」。
func TestPanelUnavailableDescriptionIsHonest(t *testing.T) {
t.Parallel()
panels := AdminPanels(testPanelInput(false))
desc := panels[0].Description
for _, want := range []string{"没有", "不在"} {
if !strings.Contains(desc, want) {
t.Errorf("不可用档的描述里缺 %q:%s", want, desc)
}
}
okDesc := AdminPanels(testPanelInput(true))[0].Description
if strings.Contains(okDesc, "⚠️") {
t.Errorf("可用档不该出现「没有可用来源」的告警:%s", okDesc)
}
}
// TestPanelDeclaresThreeReadSourcesAndTwoRowActions 钉住页面构成的**形状**:
// 三个读来源(状态/指纹/截图)与两个行内动作(拍一张 / 重新自检),且都没有 `submit`
// (行内动作是写面,submit 是另一条写面,混在一起会让「哪个按钮写了什么」不再可查)。
func TestPanelDeclaresThreeReadSourcesAndTwoRowActions(t *testing.T) {
t.Parallel()
sections := AdminPanels(testPanelInput(true))[0].Sections
sources, rowActions, submits := 0, 0, 0
for _, s := range sections {
if s.Source != nil {
sources++
}
rowActions += len(s.RowActions)
if s.Submit != nil {
submits++
}
}
if sources != 3 || rowActions != 2 || submits != 1 {
t.Fatalf("页面形状 = sources:%d rowActions:%d submits:%d, want 3/2/1", sources, rowActions, submits)
}
}
// 本文件的三个夹具:与驱动层的事实面同形(不含内容,只统计)。
func stateFixture() browsercap.StateSnapshot {
return browsercap.StateSnapshot{
Source: "本机 Chrome(有头)", Available: true, Headless: false,
ViewportWidth: 1280, ViewportHeight: 800, ProfileDir: `C:\datarowser\profile`,
ShotsDir: `C:\datarowser\shots`, Concurrency: 1,
TimeoutSeconds: 30, IdleTimeoutSeconds: 300, ScreenshotMaxBytes: 8 << 20,
}
}
func fingerprintFixture() browsercap.Fingerprint {
return browsercap.Fingerprint{
Headless: false, Webdriver: false, UserAgent: "Mozilla/5.0", Platform: "Win32",
Language: "zh-CN", Languages: []string{"zh-CN", "en"}, Timezone: "Asia/Shanghai",
ScreenWidth: 2560, ScreenHeight: 1440, ColorDepth: 24, DevicePixelRatio: 1,
}
}
func shotRecordFixture() browsercap.ShotRecord {
return browsercap.ShotRecord{
At: time.Date(2026, 10, 6, 21, 30, 0, 0, time.UTC),
Bytes: 123456, Width: 1280, Height: 800, DimensionsKnown: true, Format: "png",
}
}
// TestRowsMatchDeclaredColumns 钉住「行 key 与列 key 同源」:声明一个 key、行里写另一个
// 不会有任何报错,只会让表头下一片空白 —— 只有这类断言拦得住。
func TestRowsMatchDeclaredColumns(t *testing.T) {
t.Parallel()
fp := fingerprintFixture()
sections := AdminPanels(testPanelInput(true))[0].Sections
rows := map[string][]map[string]any{
"state": StateRows(stateFixture()),
"fingerprint": FingerprintRows(&fp, shotRecordFixture().At),
"shots": ShotRows([]browsercap.ShotRecord{shotRecordFixture()}),
}
for _, s := range sections {
if s.Source == nil {
continue
}
got := rows[s.ID]
if len(got) == 0 {
t.Errorf("区块 %s 没有对应的行夹具(用例没跟上声明)", s.ID)
continue
}
for _, row := range got {
for key := range row {
found := false
for _, c := range s.Columns {
if c.Key == key {
found = true
break
}
}
if !found {
t.Errorf("区块 %s 的行里有未声明的列 key %q", s.ID, key)
}
}
}
}
}
// TestUncheckedFingerprintIsHonest 钉住「未自检就写未自检」:面板不拿一组假数字顶替
// (那是「看起来好了」的最经典形态)。
func TestUncheckedFingerprintIsHonest(t *testing.T) {
t.Parallel()
rows := FingerprintRows(nil, time.Time{})
if len(rows) != 1 {
t.Fatalf("未自检时应给一行事实:%+v", rows)
}
for key, v := range rows[0] {
if v != notCheckedText {
t.Errorf("未自检的 %s = %v, want %q", key, v, notCheckedText)
}
}
}
// TestReadOnlyRowsCarryNoPathsOrURLs 钉住隐私口径:只读行里**没有**截图路径(只有统计)。
// 面板的只读区块连 viewer 都能看,把文件路径与截图内容投到那里是越界。
func TestReadOnlyRowsCarryNoPathsOrURLs(t *testing.T) {
t.Parallel()
for _, row := range ShotRows([]browsercap.ShotRecord{shotRecordFixture()}) {
for key, v := range row {
text, _ := v.(string)
if strings.ContainsAny(text, `/\`) {
t.Errorf("截图行 %s 里出现了路径样文本:%v", key, v)
}
}
}
}
+150
View File
@@ -0,0 +1,150 @@
package browser
// 本文件是 `browser` 面板三块只读表格的**行形状**(design/09 §5.2.1 的 section.table):
// 把驱动层的事实面(`internal/capability/browser` 的 `StateSnapshot`)翻成表格行。
//
// 两条硬口径:
//
// 1. **行 key 与声明里的列 key 同源**:两边都引用 panel.go 的 `col*` 常量 —— 声明一个
// key、行里写另一个,表现就是「表头在、数据空」,而且不会有任何报错;
// 2. **不含内容、不含路径、不含 URL**:截图只报字节/尺寸/格式,页面只报「有没有会话」。
// 面板是只读投影(viewer 也能看到表格行),把主人的浏览内容投到那里是越界。
import (
"fmt"
"strconv"
"strings"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
)
// 未自检 / 未知的占位文案。**不编造数字**:没读到的读数就写「未自检」,
// 让面板与真读数一眼可分(design/28 §6 坑 5 的同一条精神:失败如实报)。
const (
notCheckedText = "未自检"
unknownText = "未知"
enabledText = "开"
disabledText = "关"
availableText = "可用"
unavailableText = "不可用"
closedText = "已关闭"
degradedYes = "是(PNG 超限降级 JPEG)"
degradedNo = "否"
)
// StateRows 把运行状态摊成一行(表格是一行事实:这个浏览器现在长什么样)。
//
// 只有一行,因为事实只有一份 —— 多行会诱导读者以为「有多个浏览器在跑」,而并发上限
// 恒为 1(design/28 §3)是这套设计的硬约束。
func StateRows(s browsercap.StateSnapshot) []map[string]any {
availability := availableText
switch {
case s.Closed:
availability = closedText
case !s.Available:
availability = unavailableText
}
source := strings.TrimSpace(s.Source)
if source == "" {
source = unknownText
}
session := "未打开"
if s.SessionLive {
session = "已打开"
if !s.SessionOpenedAt.IsZero() {
session += "(" + formatTime(s.SessionOpenedAt) + ")"
}
}
return []map[string]any{{
colSource: source + "(会话:" + session + ")",
colAvailable: availability,
colHeadless: yesNo(s.Headless, enabledText, disabledText),
colViewport: fmt.Sprintf("%d×%d", s.ViewportWidth, s.ViewportHeight),
colProfileDir: s.ProfileDir,
colConcurrent: strconv.Itoa(s.Concurrency),
colIdleReco: strconv.Itoa(s.IdleTimeoutSeconds),
colShotCap: strconv.Itoa(s.ScreenshotMaxBytes),
}}
}
// FingerprintRows 把最后一次指纹自检摊成一行;从未自检过时给一行**如实**的「未自检」。
func FingerprintRows(fp *browsercap.Fingerprint, at time.Time) []map[string]any {
if fp == nil {
return []map[string]any{{
colWebdriver: notCheckedText,
colScreen: notCheckedText,
colTimezone: notCheckedText,
colLanguage: notCheckedText,
colUserAgent: notCheckedText,
colCheckedAt: notCheckedText,
}}
}
return []map[string]any{{
// webdriver 是**布尔事实**:false 才是正常(我们不传 --enable-automation)。
colWebdriver: yesNo(fp.Webdriver, "true(自动化痕迹没关掉!)", "false(正常)"),
colScreen: fmt.Sprintf("%d×%d @%dbit dpr=%g", fp.ScreenWidth, fp.ScreenHeight, fp.ColorDepth, fp.DevicePixelRatio),
colTimezone: orUnknown(fp.Timezone),
colLanguage: languageText(fp),
colUserAgent: orUnknown(fp.UserAgent),
colCheckedAt: formatTime(at),
}}
}
// ShotRows 把最近截图统计摊成多行(**最新在前**,与驱动的环同序)。
func ShotRows(recs []browsercap.ShotRecord) []map[string]any {
out := make([]map[string]any, 0, len(recs))
for _, r := range recs {
out = append(out, map[string]any{
colShotAt: formatTime(r.At),
colShotBytes: strconv.Itoa(r.Bytes),
colShotSize: sizeText(r),
colShotFormat: orUnknown(r.Format),
colShotDegraded: yesNo(r.Degraded, degradedYes, degradedNo),
})
}
return out
}
// sizeText 渲染像素尺寸。尺寸未知时**不拿视口冒充**(那是「我们以为的」,不是「图里的」)。
func sizeText(r browsercap.ShotRecord) string {
if !r.DimensionsKnown || r.Width <= 0 || r.Height <= 0 {
return unknownText
}
return fmt.Sprintf("%d×%d", r.Width, r.Height)
}
// languageText 渲染语言列表(首项 + 总数,避免长串把单元格撑爆)。
func languageText(fp *browsercap.Fingerprint) string {
if fp.Language == "" && len(fp.Languages) == 0 {
return unknownText
}
if len(fp.Languages) <= 1 {
return orUnknown(fp.Language)
}
return fmt.Sprintf("%s 等 %d 种", orUnknown(fp.Language), len(fp.Languages))
}
// formatTime 渲染时间(秒级可读;零值给「未知」)。
func formatTime(t time.Time) string {
if t.IsZero() {
return unknownText
}
return t.Local().Format("2006-01-02 15:04:05")
}
// orUnknown 把空串渲染成「未知」。
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return unknownText
}
return s
}
// yesNo 按真值返回一对文案。
func yesNo(v bool, yes, no string) string {
if v {
return yes
}
return no
}
+80 -7
View File
@@ -50,6 +50,10 @@ var _ sdk.Tool = (*Tool)(nil)
type Tool struct {
mgr Driver
log *slog.Logger
// outbox 是「本回合待送出的截图」附件箱(B3 闭环的浏览器侧,见 attach.go)。
// 它一定非 nil(New 里默认建一个):nil 箱子会让「截图静默不进上下文」变成
// 一个只能靠读代码才看得出的行为,而它恰恰是本片要修掉的缺口。
outbox *ShotOutbox
}
// Driver 是 Tool 需要的最小驱动面(*browsercap.Manager 实现它)。用接口而不是具体
@@ -65,12 +69,18 @@ type Driver interface {
// New 构造工具;mgr 为 nil 时工具**仍可构造**但每个动作都如实报「不可用」
// (组合根的正确做法是压根不注册它,见 wire_browser.go)。
func New(mgr Driver, opts ...Option) *Tool {
t := &Tool{mgr: mgr, log: discardLog()}
t := &Tool{mgr: mgr, log: discardLog(), outbox: NewShotOutbox()}
for _, opt := range opts {
if opt != nil {
opt(t)
}
}
if t.outbox == nil {
// WithShotOutbox(nil) 是「显式关掉附件」的写法,但工具面必须能解释自己:
// 出一个可用的箱子、由附件状态字段如实报 disabled 由**不带箱**的路径给出,
// 因此这里只兜住 nil(构造期 invariants 一处收紧,动作里就不必到处判空)。
t.outbox = NewShotOutbox()
}
return t
}
@@ -91,6 +101,46 @@ func WithLogger(l *slog.Logger) Option {
return func(t *Tool) { t.log = logger.WithSource(l, logSource) }
}
// WithShotOutbox 换用给定的截图附件箱(B3 闭环的浏览器侧,见 attach.go)。
//
// 传入 nil 当作「不换」:箱子是**必有**的(循环的装配钩子按工具名取回它,见
// `cmd/cyrene/wire_browser_context.go`),把它做成可选只会让「截图到底进不进上下文」
// 取决于装配顺序,而不是取决于设计。
func WithShotOutbox(o *ShotOutbox) Option {
if o == nil {
return func(*Tool) {}
}
return func(t *Tool) { t.outbox = o }
}
// ShotOutbox 返回本工具的附件箱。循环的装配钩子据此取走本回合待送出的截图
// (取走即消费,见 ShotOutbox.Drain)。永不为 nil。
func (t *Tool) ShotOutbox() *ShotOutbox {
if t == nil {
return nil
}
return t.outbox
}
// State 返回面板要的**只读事实快照**(design/28 §8 B4;形状见
// `internal/capability/browser/state.go`)。它**不启动浏览器、不发请求**:
// 面板的只读区块连 viewer 都能看,让「看一眼」产生进程外副作用是越界。
//
// 驱动不支持这一面(假驱动 / 未来的别的实现)时返回零值快照 —— 面板据此如实显示
// 「未知」,而不是编一组数字。
func (t *Tool) State() browsercap.StateSnapshot {
if t == nil || t.mgr == nil {
return browsercap.StateSnapshot{}
}
src, ok := t.mgr.(interface {
State() browsercap.StateSnapshot
})
if !ok {
return browsercap.StateSnapshot{}
}
return src.State()
}
// Name 实现 sdk.Tool。
func (t *Tool) Name() string { return ToolName }
@@ -119,11 +169,12 @@ func (t *Tool) Description() string {
return "Drive a real headful Chromium on this host and bring a page into the conversation. " +
"Actions: " +
"'open' navigates to an http(s) URL and returns the page's own title/url/readyState; " +
"'screenshot' captures the current viewport, saves it to disk (0600) and returns its path — " +
"pass that path to the vision or image-crop tool to actually read the page, which is how a " +
"rendered chart/QR/login page becomes visible to a text-only model; " +
"'screenshot' captures the current viewport, saves it to disk (0600) and attaches it to " +
"THIS turn automatically — a sighted main model receives the image itself, a text-only " +
"model receives a vision/OCR description — so you do not have to re-read it yourself; " +
"'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " +
"for verifying that a headful session really is headful. " +
"For a zoomed region of a large page use the image-crop tool on the returned path. " +
"Rich interaction (click/type/wait/extract/network) is NOT implemented here on purpose: " +
"configure a Playwright or Puppeteer MCP server and use its tools for that."
}
@@ -183,13 +234,19 @@ func (t *Tool) open(ctx context.Context, args map[string]any) (any, error) {
return res, nil
}
// screenshot 处理 action=screenshot:返回落盘路径与统计,「路径交 vision 读」这一步
// 由模型按描述自行发起(本层不替它调模型,也不把图塞进上下文)。
// screenshot 处理 action=screenshot:截图落盘(0600)后**把它挂进本回合的附件箱**
// (B3 闭环:循环的下一次模型调用会把它作为 image 附件送出,见 attach.go),并返回
// 落盘路径与统计。
//
// 为什么要「挂进箱子」而不是像 V5 那样只回路径:只回路径 = 把闭环交给模型自觉
// (它得记得再调一次 vision),那不是闭环。现在两条路都自动:主模型能收图 ⇒ 它看到
// 这张图;不能 ⇒ vision/OCR 工具的文字结果进上下文(design/28 §7 判据 2)。
func (t *Tool) screenshot(ctx context.Context) (any, error) {
shot, err := t.mgr.Screenshot(ctx)
if err != nil {
return nil, err
}
status := t.outbox.PutForContext(ctx, shot)
return map[string]any{
"path": shot.Path,
"bytes": shot.Bytes,
@@ -199,10 +256,26 @@ func (t *Tool) screenshot(ctx context.Context) (any, error) {
"height": shot.Height,
"dimensionsKnown": shot.DimensionsKnown,
"degraded": shot.Degraded,
"hint": "Pass this path to the vision or image-crop tool to read the page content; the image is not inlined into the conversation.",
"turnAttachment": status,
"hint": shotHint(status),
}, nil
}
// shotHint 是按附件状态给出的**如实**下一步提示(进模型上下文,因此措辞必须与事实一致:
// queued 时说「已经作为图片附件送出」,其余档说「图没进上下文,按路径读」)。
func shotHint(status string) string {
switch status {
case TurnAttachmentQueued:
return "This screenshot is queued as an attachment for the current turn and will be " +
"in your context on the next step (as the image itself, or as a vision/OCR description " +
"if your model cannot receive images). Use image-crop on the path above to read a " +
"region at full detail."
default:
return "This screenshot is NOT attached to the conversation (" + status + "). " +
"Pass the path to the vision, OCR or image-crop tool to read the page content."
}
}
// info 处理 action=info。
func (t *Tool) info(ctx context.Context) (any, error) {
fp, err := t.mgr.Fingerprint(ctx)