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

215 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}