feat(remotedevice): screensee 落盘化,回路径而非自动送模型

## 为什么

原行为把截图的 base64 直接送去视觉模型自动描述。三个问题:

1. **上下文成本**:96KB 截图 ≈ 13 万 token 的内联 base64
2. **源不可控**:生产实证 AUTO 把大图路由到不支持视觉的源,模型回
   「没有收到截图」——而我们无法区分「图没到」与「模型看不了」
3. **agent 无法决策**:不知道图存不存在,也就不知道该 describe 还是该 ocr

## 现在

截图落盘 → 回 {file,mime,size,sha256,width,height} →
agent 显式调 describe_image(path=…)/ocr_image(path=…)。
需要哪个能力由它自己判断,provider 参数也可控。

工具描述同步改写,明说「本工具不会自动描述任何内容,两步缺一不可」——
否则模型会以为拿到 file 就等于看到了画面。

## 实现

- `Registry.SaveInlineMedia(name, payload, mimeHint)`:
  解 data URL / 裸 base64 → 落盘 → 回可核对的元信息
- 文件名用**内容摘要**后缀(`<name>_<sha16>.<ext>`):
  截屏/录像会被反复抓取,用 reqID 命名会把同一张图存成多份,
  用固定名则不同内容互相覆盖 —— 摘要两个问题都避免
- `decodeInlineMedia` / `imageDimensions`:JPEG SOFn 与 PNG IHDR
  解尺寸,不解码全图;认不出的格式**省略字段而非填 0**
- 落盘失败当**工具失败**上抛,不静默退回内联:
  图已在手上却拿不到路径,agent 既没有描述也没有可查证产物
- 旧路径保留在 `auto_describe=true`(调试用)

顺带把 cmd_data_end 的媒体落盘逻辑也换成同一个方法(原来内联了
一份 MkdirAll+WriteFile 的副本)。

## 判据

新增 inline_media_test.go,六个判据经四个变异自证会红:

- 变异 A:写入截半的字节但元信息照填 → 红(只查「文件存在」会放过)
- 变异 B:尺寸写死 0 占位 → 红(元信息「看起来有」但不可信)
- 变异 C:sha256 换成假的 → 红
- 变异 D:未配置 mediaDir 时静默降级 → 红

★ 变异 C 第一次跑出「无输出」,查下来是**编译失败不是被杀**
(`sum`/`hex` 变成未使用)。这正是本项目记过的坑:
编译失败 ≠ 被杀。改写成能编译的注入后确认被杀。

## 验证

- go build ./... / go vet / go test ./internal/... 全绿
- go test -race remotedevice + multimodal 无数据竞争
- 旧判据按新契约更新时保留原意:
  TestScreenseE2E_SuccessReturnsDescription 现在同时验两条路径
  (默认不出 description 且不调 handler;auto_describe 才出)
- 部署后:39 插件加载、0 panic/fatal、plugin=multimodal
- graph.db integrity ok,blocks 2787 / relations 980 未动
- 快照 /home/newqqagent/memory/pre-screensee-persist-20261006-091536/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JianFeeeee
2026-10-06 09:16:15 +08:00
parent a02d8b00a5
commit a7d048c922
5 changed files with 460 additions and 40 deletions

View File

@ -11,6 +11,7 @@ import (
"image/png"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
@ -187,6 +188,8 @@ func TestScreenseE2E_VisualFailureIsErrorNotDescription(t *testing.T) {
reg := NewRegistry()
token := "tk-visual-fail"
reg.SetAcceptToken(func(p string) bool { return p == token })
// auto_describe 需要落盘目录先存在(默认路径也会落盘)
reg.SetMediaDir(t.TempDir())
dev := &devicectlDevice{reg: reg}
// 视觉源返回 403(MCP/screensee 生产上真实发生过)
@ -224,7 +227,7 @@ func TestScreenseE2E_VisualFailureIsErrorNotDescription(t *testing.T) {
}
}()
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vf-dev"})
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vf-dev", "auto_describe": true})
// ★ 必须报错
if err == nil {
@ -285,6 +288,9 @@ func TestScreenseE2E_SuccessReturnsDescription(t *testing.T) {
}
}()
// ★★ 2026-10-06:默认路径**不再自动描述**,改为落盘 + 回路径。
// 旧行为保留在 auto_describe=true 下,另有一处判据覆盖。
reg.SetMediaDir(t.TempDir())
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vo-dev"})
if err != nil {
t.Fatalf("成功路径被破坏: %v", err)
@ -293,11 +299,33 @@ func TestScreenseE2E_SuccessReturnsDescription(t *testing.T) {
if !ok {
t.Fatalf("返回类型变了: %T", res)
}
if m["description"] != "屏幕上是一个终端窗口" {
t.Errorf("description 不对: %v", m["description"])
fp, _ := m["file"].(string)
if fp == "" {
t.Fatalf("默认路径必须回 file,实际返回: %v", m)
}
if got, err := os.ReadFile(fp); err != nil || len(got) == 0 {
t.Errorf("file 应指向落盘的截图: err=%v", err)
}
if _, hasDesc := m["description"]; hasDesc {
t.Error("默认路径不应再返回 description(内容由 describe_image 显式取)")
}
if gotLen != 0 {
t.Error("默认路径不应调用视觉描述 handler(模型必须显式调 describe_image)")
}
// ★ auto_describe=true 走旧路径(调试用):handler 被调,description 回来。
res2, err := dev.Execute("screensee", map[string]interface{}{
"device_id": "vo-dev", "auto_describe": true,
})
if err != nil {
t.Fatalf("auto_describe 路径被破坏: %v", err)
}
m2, _ := res2.(map[string]interface{})
if m2["description"] != "屏幕上是一个终端窗口" {
t.Errorf("auto_describe 应返回 description,实际: %v", m2["description"])
}
if gotLen == 0 {
t.Error("handler 没收到 dataURL")
t.Error("auto_describe 下 handler 应收到 dataURL")
}
}