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)
}

View File

@ -102,15 +102,22 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef {
},
{
Name: "screensee",
Description: "查看一台已授权设备的屏幕当前画面(截屏回传)。" +
"与 screensue(向用户屏幕显示内容)配对:screensue 是给用户看,screensee 是你看。" +
"返回屏幕截图的自动视觉描述;如需读取屏上文字可接着用 ocr_image。" +
Description: "截取一台已授权设备的当前屏幕画面。\n" +
"与 screensue(向用户屏幕显示内容)配对:screensue 是给用户看,screensee 是你看。\n" +
"★★ 返回的是截图的**落盘路径**与元信息(file/mime/size/sha256/width/height)," +
"**不含画面内容**。要看画面或读屏上文字,必须接着调用:" +
"describe_image(path=<file>) 看画面,或 ocr_image(path=<file>) 读文字。" +
"两步缺一不可——本工具不会自动描述任何东西。\n" +
"需要 device_id(来自 devicedetect)。设备必须已授权且在线。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
"provider": map[string]interface{}{"type": "string", "description": "可选:用于视觉描述的 LLM 源名称,不填则使用默认模型"},
"auto_describe": map[string]interface{}{
"type": "boolean",
"description": "可选(默认 false):true 时跳过落盘、直接用视觉模型描述" +
"(仅调试用:大截图约 13 万 token,且视觉源不可控)",
},
},
"required": []interface{}{"device_id"},
},
@ -372,7 +379,11 @@ func (d *devicectlDevice) SetSeeHandler(fn func(dataURL string, provider string)
}
// screensee 实现 screensee:向设备下发 homeagent-screensee 截屏命令,
// 等待回传 jpeg base64,交给 seeHandler(agent 核心)做视觉描述。
// screensee 截取设备屏幕。
//
// ★★ 2026-10-06:不再自动送视觉模型,改为**落盘 + 回路径**,
// 由 agent 显式调 describe_image(path=…)/ocr_image(path=…)。见函数体注释。
// args: device_id(必填)、provider(仅 auto_describe 时用)、auto_describe(调试用,默认 false)。
func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, error) {
id, _ := args["device_id"].(string)
provider, _ := args["provider"].(string)
@ -420,27 +431,66 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e
// · 前若干字节的 base64 片段 + SHA256 前 16 hex
// ⇒ 「回传的是不是同一张图」「是不是被截断了」一眼可判。
logScreenseePayload(id, output)
// 设备端回传 data URL(data:image/jpeg;base64,...)或裸 base64
if !strings.HasPrefix(output, "data:") {
output = "data:image/jpeg;base64," + output
// ★★ 2026-10-06:截图先**落盘**,再把路径交给 agent。
//
// 原行为:把 base64 直接送去视觉模型自动描述。三个问题:
// ① 96KB 截图 ≈ 13 万 token 的内联 base64;
// ② 自动挑源不可控 —— 生产实证:AUTO 把大图路由到不支持视觉的源,
// 模型回「没有收到截图」,而我们无法区分「图没到」与「模型看不了」;
// ③ agent 不知道图存不存在,也就无法决定用 describe 还是 ocr。
//
// 现在:落盘 + 回元信息,agent 显式调 describe_image(path=…)/ocr_image(path=…)
// —— 需要哪个能力由它自己判断,且 provider 参数可控。
//
// 落盘失败当**工具失败**上抛(不静默退回内联):图已经在手上却拿不到路径,
// agent 会既没有描述也没有可查证产物,只剩一句「失败」。
//
// auto_describe=true 保留旧路径(调试用)。
if auto, _ := args["auto_describe"].(bool); auto && d.seeHandler != nil {
if !strings.HasPrefix(output, "data:") {
output = "data:image/jpeg;base64," + output
}
desc, err := d.seeHandler(output, provider)
if err != nil {
// ★ 视觉描述失败 ⇒ 工具失败(不是「返回一段说明」)。
// 生产实证:403 被塞进 description 且 status=ok,
// agent 以为拿到了结果,于是换 provider 又试一次(再失败一次)。
d.reg.SaveResult(reqID, map[string]interface{}{
"error": err.Error(),
"device": id,
"has_image": true, // 截图确实拿到了:区分「设备没回传」与「模型看不了」
})
return nil, fmt.Errorf("设备已回传截图,但视觉描述失败: %w", err)
}
return map[string]interface{}{"description": desc}, nil
}
d.reg.SaveResult(reqID, res)
if d.seeHandler == nil {
return map[string]interface{}{"image_data_url": output, "note": "无视觉描述处理器,仅返回原始图像数据"}, nil
}
desc, err := d.seeHandler(output, provider)
meta, err := d.reg.SaveInlineMedia("screensee_"+reqID, output, "image/jpeg")
if err != nil {
// ★ 视觉描述失败 ⇒ 工具失败(不是「返回一段说明」)。
// 生产实证:403 被塞进 description 且 status=ok,
// agent 以为拿到了结果,于是换 provider 又试一次(再失败一次)。
d.reg.SaveResult(reqID, map[string]interface{}{
"error": err.Error(),
"device": id,
"has_image": true, // 截图确实拿到了:区分「设备没回传」与「模型看不了」
"has_image": true, // 图确实拿到了,只是没落盘
})
return nil, fmt.Errorf("设备已回传截图,但视觉描述失败: %w", err)
return nil, fmt.Errorf("截图已回传但落盘失败(无法交付给多模态工具): %w", err)
}
return map[string]interface{}{"description": desc}, nil
d.reg.SaveResult(reqID, meta)
out := map[string]interface{}{
"file": meta["file"],
"mime": meta["mime"],
"size": meta["size"],
"sha256": meta["sha256"],
"device": id,
"next_step": "如需查看画面,请调用 describe_image 并传入 path 参数;需读图中的文字请用 ocr_image。",
}
if w, ok := meta["width"]; ok {
out["width"] = w
}
if h, ok := meta["height"]; ok {
out["height"] = h
}
return out, nil
}
// computeruse 实现 computeruse:向设备下发鼠标/键盘控制命令。

View File

@ -0,0 +1,194 @@
package remotedevice
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"strings"
"testing"
)
// screensee 落盘化(2026-10-06)
//
// ## 为什么要落盘
//
// 原行为是把 96KB 截图的 base64 直接送去视觉模型:
// · 约 13 万 token 的内联载荷
// · 自动挑源不可控(生产实证:AUTO 路由到不支持视觉的源,模型说「没收到图」)
// · agent 不知道图存不存在,无法决定该 describe 还是该 ocr
//
// 现在:截图落盘 → 回 {file,mime,size,sha256,width,height} →
// agent 显式调 describe_image(path=…) / ocr_image(path=…)
//
// 这些判据钉住「真的落盘了」与「元信息可信」——后者是 agent 决定
// 下一步的唯一依据,写错了它就会去读一个不存在的文件。
func jpegBytes(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 0x40, A: 0xff})
}
}
var buf strings.Builder
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return []byte(buf.String())
}
// ---- 落盘:文件存在且字节逐字节一致 ----
func TestSaveInlineMedia_DataURLWritesExactBytes(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 64, 48)
meta, err := reg.SaveInlineMedia("shot", "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw), "")
if err != nil {
t.Fatalf("落盘应成功: %v", err)
}
fp, _ := meta["file"].(string)
if fp == "" {
t.Fatalf("meta 必须含 file,实际: %v", meta)
}
got, err := os.ReadFile(fp)
if err != nil {
t.Fatalf("file 应可读: %v", err)
}
// ★ 逐字节比对:只查「文件存在」会放过内容被截断/转码的情况。
if string(got) != string(raw) {
t.Errorf("落盘字节与原图不一致: 写入 %d 字节, 原图 %d 字节", len(got), len(raw))
}
if meta["size"] != len(raw) {
t.Errorf("meta.size=%v,实际 %d", meta["size"], len(raw))
}
}
func TestSaveInlineMedia_BareBase64AlsoWorks(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 32, 32)
// 设备端有时只回裸 base64(无 data: 前缀)
meta, err := reg.SaveInlineMedia("bare", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatalf("裸 base64 应可落盘: %v", err)
}
got, _ := os.ReadFile(meta["file"].(string))
if string(got) != string(raw) {
t.Error("裸 base64 落盘字节不一致")
}
if meta["mime"] != "image/jpeg" {
t.Errorf("mimeHint 未生效: %v", meta["mime"])
}
}
// ---- 元信息可信:agent 全靠它决定下一步 ----
func TestSaveInlineMedia_MetaIsVerifiable(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 128, 96)
meta, err := reg.SaveInlineMedia("meta", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatal(err)
}
// sha256 必须与文件内容对得上(不是随机的/占位的)
got, _ := os.ReadFile(meta["file"].(string))
sum := sha256Hex(got)
if meta["sha256"] != sum {
t.Errorf("sha256 与落盘内容不符: meta=%v 实际=%v", meta["sha256"], sum)
}
// 尺寸必须是真解出来的,不是 0 或缺省
if meta["width"] != 128 || meta["height"] != 96 {
t.Errorf("尺寸应解出 128x96,实际 %vx%v", meta["width"], meta["height"])
}
}
// TestSaveInlineMedia_ContentAddressedName 钉住文件名含内容摘要。
//
// ★ 为什么要摘要后缀:截屏/录像会被反复抓取。用 reqID 命名会把同一张图
//
// 存成多份;用固定名则不同内容互相覆盖。摘要两个问题都避免。
func TestSaveInlineMedia_ContentAddressedName(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 16, 16)
m1, err := reg.SaveInlineMedia("req-1", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatal(err)
}
// 同内容不同 name ⇒ 摘要部分相同(内容寻址的语义)
m2, err := reg.SaveInlineMedia("req-2", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if filepath.Base(m1["file"].(string)) == filepath.Base(m2["file"].(string)) {
t.Error("不同 name 的同内容落盘,文件名不应完全相同(name 前缀不同)")
}
if !strings.HasPrefix(filepath.Base(m1["file"].(string)), "req-1_") {
t.Errorf("文件名应保留 name 前缀,实际 %s", filepath.Base(m1["file"].(string)))
}
if !strings.HasSuffix(m1["file"].(string), ".jpg") {
t.Errorf("应按 mime 推出 .jpg 后缀,实际 %s", m1["file"])
}
}
// ---- 失败必须报错,不得静默降级 ----
func TestSaveInlineMedia_FailuresAreExplicit(t *testing.T) {
reg := NewRegistry()
// 未配置落盘目录
if _, err := reg.SaveInlineMedia("x", "data:image/jpeg;base64,AAAA", ""); err == nil {
t.Error("未配置 mediaDir 时必须报错(静默降级会让 agent 拿不到路径也无从排查)")
}
reg.SetMediaDir(t.TempDir())
for name, payload := range map[string]string{
"空载荷": "",
"只有空白": " ",
"非法base64": "data:image/jpeg;base64,!!!not-base64!!!",
"缺逗号": "data:image/jpeg;base64",
"解出空内容": "data:image/jpeg;base64,",
} {
if _, err := reg.SaveInlineMedia(name, payload, ""); err == nil {
t.Errorf("%s 应报错,实际返回成功", name)
}
}
}
// ---- 尺寸解析:不认识的格式省略字段,而不是填 0 ----
func TestImageDimensions(t *testing.T) {
// JPEG
if w, h, ok := imageDimensions(jpegBytes(t, 200, 150)); !ok || w != 200 || h != 150 {
t.Errorf("JPEG 尺寸解析错: %dx%d ok=%v", w, h, ok)
}
// PNG(存一个真 PNG 头)
png := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13,
'I', 'H', 'D', 'R', 0, 0, 1, 44, // 300
0, 0, 1, 0x90, // 400
8, 6, 0, 0, 0}
if w, h, ok := imageDimensions(png); !ok || w != 300 || h != 400 {
t.Errorf("PNG 尺寸解析错: %dx%d ok=%v", w, h, ok)
}
// 认不出的格式 ⇒ ok=false,**不得**返回 0,0,true
if _, _, ok := imageDimensions([]byte("not an image at all")); ok {
t.Error("非图片应返回 ok=false")
}
if _, _, ok := imageDimensions(nil); ok {
t.Error("空输入应返回 ok=false")
}
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}

View File

@ -2,9 +2,12 @@ package remotedevice
import (
"bufio"
"bytes"
"crypto/sha1" // RFC6455 规定 Sec-WebSocket-Accept 必须用 SHA-1,勿改
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@ -155,6 +158,145 @@ func (r *Registry) SetMediaDir(dir string) {
r.mu.Unlock()
}
// SaveInlineMedia 把一段内联媒体(data URL 或裸 base64)落盘,返回文件元信息。
//
// ★ 为什么抽出来(2026-10-06):screensee 之前只把 base64 塞给视觉模型,
// 带来两个问题——
//
// ① **上下文成本**:96KB 的截图 = 约 13 万 token 的内联 base64;
// ② **诊断不了**:模型说「没收到图片」时,服务端无法证明图到底在不在
// (只能靠日志里手写的元信息)。
//
// 落盘 + 回路径后,agent 可以**显式**调 describe_image(path=…),
// 且「图存在」这件事有可查证的落盘产物。
//
// 返回 meta(含 file/mime/size/sha256/width/height);mediaDir 为空或
// 写入失败时返回 error —— **不静默降级成内联**:调用方要据此决定是否
// 退回旧行为,而静默回退正是「失败伪装成成功」的源头。
func (r *Registry) SaveInlineMedia(name, payload, mimeHint string) (map[string]interface{}, error) {
r.mu.RLock()
dir := r.mediaDir
r.mu.RUnlock()
if dir == "" {
return nil, fmt.Errorf("未配置媒体落盘目录(SetMediaDir 未调用)")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建媒体目录 %s 失败: %w", dir, err)
}
raw, mt, err := decodeInlineMedia(payload, mimeHint)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, fmt.Errorf("媒体内容为空(%s)", name)
}
// 文件名用内容摘要:同一张图重复截屏不堆文件,不同图不覆盖。
sum := sha256.Sum256(raw)
digest := hex.EncodeToString(sum[:])
ext := mediaExt(mt, "")
if ext == "" {
ext = ".bin"
}
fp := filepath.Join(dir, name+"_"+digest[:16]+ext)
if err := os.WriteFile(fp, raw, 0o644); err != nil {
return nil, fmt.Errorf("写媒体文件 %s 失败: %w", fp, err)
}
meta := map[string]interface{}{
"file": fp,
"mime": mt,
"size": len(raw),
"sha256": digest,
}
if w, h, ok := imageDimensions(raw); ok {
meta["width"] = w
meta["height"] = h
}
return meta, nil
}
// decodeInlineMedia 把 data URL 或裸 base64 解成字节 + mime。
func decodeInlineMedia(payload, mimeHint string) ([]byte, string, error) {
p := strings.TrimSpace(payload)
if p == "" {
return nil, "", fmt.Errorf("媒体载荷为空")
}
mime := mimeHint
if strings.HasPrefix(p, "data:") {
// data:[<mime>][;base64],<payload>
comma := strings.IndexByte(p, ',')
if comma < 0 {
return nil, "", fmt.Errorf("data URL 缺少逗号分隔符")
}
metaPart := p[len("data:"):comma]
if strings.Contains(metaPart, "base64") {
if i := strings.IndexByte(metaPart, ';'); i >= 0 {
mime = metaPart[:i]
} else {
mime = metaPart
}
} else if mime == "" {
mime = metaPart
}
p = p[comma+1:]
}
if mime == "" {
mime = "image/jpeg"
}
raw, err := base64.StdEncoding.DecodeString(p)
if err != nil {
// 有些设备会塞换行/URL-safe 变体
raw2, err2 := base64.RawStdEncoding.DecodeString(strings.TrimSpace(strings.NewReplacer("\n", "", "\r", "").Replace(p)))
if err2 != nil {
return nil, "", fmt.Errorf("base64 解码失败: %w", err)
}
raw = raw2
}
return raw, mime, nil
}
// imageDimensions 从图片头部解出像素尺寸,不解码全图。
// 不认识的格式返回 ok=false —— 调用方据此省略字段,而不是填 0。
func imageDimensions(raw []byte) (int, int, bool) {
if len(raw) < 24 {
return 0, 0, false
}
// PNG: \x89PNG\r\n\x1a\n + IHDR(width,height BE32 @16,20)
if bytes.HasPrefix(raw, []byte{0x89, 'P', 'N', 'G'}) {
return int(raw[16])<<24 | int(raw[17])<<16 | int(raw[18])<<8 | int(raw[19]),
int(raw[20])<<24 | int(raw[21])<<16 | int(raw[22])<<8 | int(raw[23]), true
}
// JPEG: 扫 SOFn 段
if bytes.HasPrefix(raw, []byte{0xFF, 0xD8}) {
for i := 2; i+9 < len(raw); {
if raw[i] != 0xFF {
i++
continue
}
marker := raw[i+1]
// SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15
if marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
h := int(raw[i+5])<<8 | int(raw[i+6])
w := int(raw[i+7])<<8 | int(raw[i+8])
if w > 0 && h > 0 {
return w, h, true
}
}
if i+3 >= len(raw) {
break
}
segLen := int(raw[i+2])<<8 | int(raw[i+3])
if segLen < 2 {
break
}
i += 2 + segLen
}
}
return 0, 0, false
}
// mediaExt 按 mime/kind 推断扩展名。
func mediaExt(mime, kind string) string {
m := strings.ToLower(mime)
@ -913,22 +1055,17 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter, handshakeAuthor
// 媒体落盘模式:写入 <mediaDir>/<reqID>.<ext>,cmd_result 返回 file 路径。
// 大体积 base64 内联会撑爆 LLM 上下文(一段 10s 录像即数 MB),
// agent 应拿路径后用 files/describe_image/ocr 等工具消费。
r.mu.RLock()
mediaDir := r.mediaDir
r.mu.RUnlock()
if mediaDir != "" {
if err := os.MkdirAll(mediaDir, 0755); err == nil {
fp := filepath.Join(mediaDir, reqID+mediaExt(acc.mime, acc.kind))
if werr := os.WriteFile(fp, data, 0644); werr == nil {
res["file"] = fp
} else {
log.Printf("[remotedevice] media write %s: %v", fp, werr)
}
} else {
log.Printf("[remotedevice] media dir %s: %v", mediaDir, err)
if meta, err := r.SaveInlineMedia(reqID, base64.StdEncoding.EncodeToString(data), acc.mime); err == nil {
for k, v := range meta {
res[k] = v
}
} else {
log.Printf("[remotedevice] media save %s: %v", reqID, err)
}
// 未配置落盘目录时保持旧行为:base64 内联返回(小体积数据仍可用)
// 未落盘时保持旧行为:base64 内联返回(小体积数据仍可用)。
// ★ 这里仍内联是可接受的退化——上面的 log 已记录原因。
// screensee 那边不同:它把落盘失败当**工具失败**上抛,
// 因为「拿到了图却没落盘」会让 agent 拿不到路径也无从排查。
if _, hasFile := res["file"]; !hasFile {
res["data_base64"] = base64.StdEncoding.EncodeToString(data)
}

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")
}
}