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