- 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 已写盯梢用例)
262 lines
10 KiB
Go
262 lines
10 KiB
Go
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 ""
|
||
}
|