mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
@ -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)
|
||||
}
|
||||
|
||||
@ -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:向设备下发鼠标/键盘控制命令。
|
||||
|
||||
194
internal/plugins/remotedevice/inline_media_test.go
Normal file
194
internal/plugins/remotedevice/inline_media_test.go
Normal 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[:])
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user