- B3:截图落盘后**自动**并入本轮视图(能收图 ⇒ 真 image 部件挂 user 消息;收不了 ⇒ vision/OCR 文字;
大图先经 image-crop 裁切;取走即消费、每会话 ≤4 张、TTL 10min、跨会话隔离)
- B4:browser 面板**声明**(配置表单 + 运行状态/指纹自检/最近截图三张只读表 + 拍一张/重新自检)
+ 六个动作走 PLG-2 的通用动作端点(**不开新路由**)+ 保存事务(不可用则回滚 409)+ 热重载
- 读面**不启动浏览器**(计数型假 dialer 证明 0 次拨号);行数据不含路径/URL/内容
- 3 个变异(短路主路/行与列 key 脱钩/保存不回滚)
⚠️ **两行接线未落**(不在我文件面):wire_runtime.go 的 TransformContext + wire_frontend.go 的
wireBrowserPanel —— 落地前 B3/B4 只是「组件就绪」,跑着的后端里不存在(Y4 已写盯梢用例)
215 lines
9.2 KiB
Go
215 lines
9.2 KiB
Go
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
|
||
}
|