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