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 }