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

@ -298,8 +298,15 @@ func TestWSBinaryMediaToFile(t *testing.T) {
if _, hasB64 := res["data_base64"]; hasB64 {
t.Fatal("data_base64 should be absent in file mode")
}
if want := filepath.Join(mediaDir, "req-file-1.mp4"); fp != want {
t.Fatalf("file path = %s, want %s", fp, want)
// ★ 2026-10-06:文件名含内容摘要(SaveInlineMedia)。
// 截屏/录像这类会被反复抓取的媒体,用 reqID 命名会把同内容存成多份、
// 不同内容却可能覆盖 —— 摘要后缀两<E7BC80><E4B8A4><EFBFBD>都避免。
if filepath.Dir(fp) != mediaDir {
t.Fatalf("file 应落在媒体目录 %s,实际 %s", mediaDir, fp)
}
base := filepath.Base(fp)
if !strings.HasPrefix(base, "req-file-1_") || !strings.HasSuffix(base, ".mp4") {
t.Fatalf("文件名应为 req-file-1_<digest>.mp4,实际 %s", base)
}
got, err := os.ReadFile(fp)
if err != nil {
@ -402,6 +409,7 @@ func TestPushDataOfflineDevice(t *testing.T) {
func TestScreenseeEndToEnd(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
token := "test-token-see"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
@ -449,7 +457,10 @@ func TestScreenseeEndToEnd(t *testing.T) {
}()
// agent 调用 screensee
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"})
// ★ 2026-10-06:默认走落盘 + 回路径;旧的自<E79A84><E887AA><EFBFBD>描述路径用 auto_describe=true。
res, err := dev.Execute("screensee", map[string]interface{}{
"device_id": "see-dev", "auto_describe": true,
})
if err != nil {
t.Fatalf("screensee: %v", err)
}