diff --git a/cmd/cyrene/wire_browser.go b/cmd/cyrene/wire_browser.go index f93fbef..cf305d4 100644 --- a/cmd/cyrene/wire_browser.go +++ b/cmd/cyrene/wire_browser.go @@ -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 } diff --git a/cmd/cyrene/wire_browser_context.go b/cmd/cyrene/wire_browser_context.go new file mode 100644 index 0000000..360a9d0 --- /dev/null +++ b/cmd/cyrene/wire_browser_context.go @@ -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 +} diff --git a/cmd/cyrene/wire_browser_context_test.go b/cmd/cyrene/wire_browser_context_test.go new file mode 100644 index 0000000..2ed3d41 --- /dev/null +++ b/cmd/cyrene/wire_browser_context_test.go @@ -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 "" +} diff --git a/cmd/cyrene/wire_browser_panel.go b/cmd/cyrene/wire_browser_panel.go new file mode 100644 index 0000000..7fc09d9 --- /dev/null +++ b/cmd/cyrene/wire_browser_panel.go @@ -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 +} diff --git a/cmd/cyrene/wire_browser_panel_data.go b/cmd/cyrene/wire_browser_panel_data.go new file mode 100644 index 0000000..507ddbb --- /dev/null +++ b/cmd/cyrene/wire_browser_panel_data.go @@ -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) + } +} diff --git a/cmd/cyrene/wire_browser_panel_test.go b/cmd/cyrene/wire_browser_panel_test.go new file mode 100644 index 0000000..fef92c5 --- /dev/null +++ b/cmd/cyrene/wire_browser_panel_test.go @@ -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) + } +} diff --git a/internal/capability/browser/manager.go b/internal/capability/browser/manager.go index a585ac7..1380f5a 100644 --- a/internal/capability/browser/manager.go +++ b/internal/capability/browser/manager.go @@ -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() }) diff --git a/internal/capability/browser/state.go b/internal/capability/browser/state.go new file mode 100644 index 0000000..80f14f4 --- /dev/null +++ b/internal/capability/browser/state.go @@ -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 +} diff --git a/internal/capability/browser/state_test.go b/internal/capability/browser/state_test.go new file mode 100644 index 0000000..43f09f0 --- /dev/null +++ b/internal/capability/browser/state_test.go @@ -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) + } +} diff --git a/internal/runtime/multimodal/vision/resolver.go b/internal/runtime/multimodal/vision/resolver.go index bce61d6..c44e9d8 100644 --- a/internal/runtime/multimodal/vision/resolver.go +++ b/internal/runtime/multimodal/vision/resolver.go @@ -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{ diff --git a/internal/runtime/multimodal/vision/shot.go b/internal/runtime/multimodal/vision/shot.go new file mode 100644 index 0000000..389a8fb --- /dev/null +++ b/internal/runtime/multimodal/vision/shot.go @@ -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:;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{}) +} diff --git a/internal/runtime/multimodal/vision/shot_test.go b/internal/runtime/multimodal/vision/shot_test.go new file mode 100644 index 0000000..619c550 --- /dev/null +++ b/internal/runtime/multimodal/vision/shot_test.go @@ -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") + } +} diff --git a/plugins/tools/browser/attach.go b/plugins/tools/browser/attach.go new file mode 100644 index 0000000..b044b0c --- /dev/null +++ b/plugins/tools/browser/attach.go @@ -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) +} diff --git a/plugins/tools/browser/attach_test.go b/plugins/tools/browser/attach_test.go new file mode 100644 index 0000000..bf990b5 --- /dev/null +++ b/plugins/tools/browser/attach_test.go @@ -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) + } +} diff --git a/plugins/tools/browser/panel.go b/plugins/tools/browser/panel.go new file mode 100644 index 0000000..9bc590d --- /dev/null +++ b/plugins/tools/browser/panel.go @@ -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 } diff --git a/plugins/tools/browser/panel_test.go b/plugins/tools/browser/panel_test.go new file mode 100644 index 0000000..ae44ef8 --- /dev/null +++ b/plugins/tools/browser/panel_test.go @@ -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) + } + } + } +} diff --git a/plugins/tools/browser/paneldata.go b/plugins/tools/browser/paneldata.go new file mode 100644 index 0000000..102497e --- /dev/null +++ b/plugins/tools/browser/paneldata.go @@ -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 +} diff --git a/plugins/tools/browser/tool.go b/plugins/tools/browser/tool.go index a0e6ee0..f9d9682 100644 --- a/plugins/tools/browser/tool.go +++ b/plugins/tools/browser/tool.go @@ -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)