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:
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user