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:
AskaEth committed 2026-10-06 21:48:50 +08:00
1 parent 4213b268fe
commit e0a6bca501
18 files changed
+3698 -72

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
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 ""
}