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:
@ -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:向设备下发鼠标/键盘控制命令。
|
||||
|
||||
Reference in New Issue
Block a user