mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 为什么
原行为把截图的 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>
370 lines
13 KiB
Go
370 lines
13 KiB
Go
package remotedevice
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"image"
|
||
"image/color"
|
||
"image/jpeg"
|
||
"image/png"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// screensee 的两条教训(2026-10-05 生产日志实证)
|
||
//
|
||
// ## ① 视觉描述失败必须以 error 上抛,不能渲染成普通文本
|
||
//
|
||
// 原实现:`return fmt.Sprintf("屏幕截图视觉描述失败: %v…", err)`
|
||
// 生产实测(13:51):
|
||
// {"description":"屏幕截图视觉描述失败: api error 403:
|
||
// {\"error\":{\"message\":\"model \"gpt-5.6-sol\" is not allowed…"}
|
||
// ⇒ status=ok、只有 description 字段 ⇒ **失败与成功同形**。
|
||
// agent 以为拿到了结果,于是换 provider 再试一次(又失败一次)。
|
||
//
|
||
// 与同一天修掉的 MCP `isError` 丢失是**同一个病**。
|
||
//
|
||
// ★ 教训:不能只断言「返回值里含错误文本」——
|
||
// 那样「把错误写成 description」照样通过。必须断言**它从 error 通道出来**。
|
||
|
||
// TestSeeHandler_ErrorPropagatesAsError 钉住 seeHandler 的 error 不会被吞成文本。
|
||
func TestSeeHandler_ErrorPropagatesAsError(t *testing.T) {
|
||
reg := NewRegistry()
|
||
dev := &devicectlDevice{reg: reg}
|
||
|
||
// 模拟「视觉源 403」:handler 返回 error
|
||
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
|
||
return "", errFake403
|
||
})
|
||
|
||
// 直接验契约:签名本身允许 error 通道
|
||
var f func(string, string) (string, error) = dev.seeHandler
|
||
desc, err := f("data:image/jpeg;base64,AAAA", "")
|
||
if err == nil {
|
||
t.Fatal("handler 返回 error 时,调用方必须能拿到 error(否则又会被渲染成 description)")
|
||
}
|
||
if desc != "" {
|
||
t.Errorf("失败时不应同时返回描述文本,实际 %q", desc)
|
||
}
|
||
if !strings.Contains(err.Error(), "403") {
|
||
t.Errorf("错误信息应保留上游原文供诊断,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSeeHandler_SuccessStillWorks 钉住成功路径不受影响。
|
||
func TestSeeHandler_SuccessStillWorks(t *testing.T) {
|
||
reg := NewRegistry()
|
||
dev := &devicectlDevice{reg: reg}
|
||
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
|
||
return "屏幕上是一个终端窗口", nil
|
||
})
|
||
desc, err := dev.seeHandler("data:image/jpeg;base64,AAAA", "")
|
||
if err != nil || desc == "" {
|
||
t.Fatalf("成功路径被破坏:desc=%q err=%v", desc, err)
|
||
}
|
||
}
|
||
|
||
// TestLogScreenseePayload_IdentifiesContent 钉住「回传内容可查证」。
|
||
//
|
||
// ★ 这条对应那两次无从查证的「我收不到截图」:
|
||
// 服务端日志必须能回答「设备到底回传了什么」。
|
||
// 现在记录 字节数 / magic / 像素尺寸 / base64 头 / SHA256 前缀。
|
||
|
||
func TestLogScreenseePayload_IdentifiesContent(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
data []byte
|
||
wantW int
|
||
wantH int
|
||
}{
|
||
{"jpeg 8x4", makeJPEG(t, 8, 4), 8, 4},
|
||
{"jpeg 1920x1080", makeJPEG(t, 1920, 1080), 1920, 1080},
|
||
}
|
||
for _, c := range cases {
|
||
url := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(c.data)
|
||
logScreenseePayload("dev1", url)
|
||
}
|
||
// png(magic 走 png 分支,尺寸解不出 → 0 0,也应正常不 panic)
|
||
pngURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(makePNG(t, 3, 3))
|
||
logScreenseePayload("dev1", pngURL)
|
||
// 空
|
||
logScreenseePayload("dev1", "")
|
||
// 非法 base64(被截断的典型形态)
|
||
logScreenseePayload("dev1", "data:image/jpeg;base64,!!!not-base64!!!")
|
||
}
|
||
|
||
// TestJpegSize 单独钉住 SOF 解析:尺寸是判断「图是不是真的截到了」的关键信号。
|
||
func TestJpegSize(t *testing.T) {
|
||
for _, dim := range [][2]int{{1, 1}, {640, 480}, {1920, 1080}, {2560, 1440}} {
|
||
b := makeJPEG(t, dim[0], dim[1])
|
||
w, h := jpegSize(b)
|
||
if w != dim[0] || h != dim[1] {
|
||
t.Errorf("%dx%d: jpegSize 给 %dx%d", dim[0], dim[1], w, h)
|
||
}
|
||
}
|
||
// 非 jpeg 一律 0,0,且不得 panic
|
||
if w, h := jpegSize([]byte("not an image")); w != 0 || h != 0 {
|
||
t.Errorf("非 jpeg 应给 0,0,实际 %dx%d", w, h)
|
||
}
|
||
if w, h := jpegSize(nil); w != 0 || h != 0 {
|
||
t.Errorf("nil 应给 0,0,实际 %dx%d", w, h)
|
||
}
|
||
// ★ 截断的 jpeg(真实故障形态):不得 panic,且不该报出假的尺寸
|
||
trunc := makeJPEG(t, 100, 100)
|
||
if w, _ := jpegSize(trunc[:20]); w != 0 {
|
||
t.Errorf("截断的 jpeg 不应报出宽度,实际 %d", w)
|
||
}
|
||
}
|
||
|
||
func TestMagicName(t *testing.T) {
|
||
if got := magicName(makeJPEG(t, 2, 2)); got != "jpeg" {
|
||
t.Errorf("jpeg 识别错: %s", got)
|
||
}
|
||
if got := magicName(makePNG(t, 2, 2)); got != "png" {
|
||
t.Errorf("png 识别错: %s", got)
|
||
}
|
||
if got := magicName(nil); got != "empty" {
|
||
t.Errorf("nil 应为 empty,实际 %s", got)
|
||
}
|
||
if got := magicName([]byte("garbage")); got != "unknown" {
|
||
t.Errorf("垃圾数据应为 unknown,实际 %s", got)
|
||
}
|
||
}
|
||
|
||
func makeJPEG(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: 128, A: 255})
|
||
}
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func makePNG(t *testing.T, w, h int) []byte {
|
||
t.Helper()
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
var errFake403 = &fakeErr{"api error 403: model \"gpt-5.6-sol\" is not allowed for this key"}
|
||
|
||
type fakeErr struct{ s string }
|
||
|
||
func (e *fakeErr) Error() string { return e.s }
|
||
|
||
// ===== 以下走完整链路(真 WS + 真 registry),盯住「失败从哪个通道出来」 =====
|
||
|
||
// TestScreenseE2E_VisualFailureIsErrorNotDescription 是本次修改的**核心门禁**。
|
||
//
|
||
// ★ 为什么必须走完整链路:
|
||
//
|
||
// 我第一版判据只测 `seeHandler` 的签名(能不能返回 error)——
|
||
// 把调用点的 `if err != nil` 改成「把错误渲染成 description 文本」时,
|
||
// **判据全绿**。因为被测的是「签名允许 error」,
|
||
// 而真正的缺陷在「调用点有没有真的走 error 通道」。
|
||
// ⇒ 判据必须驱动 `dev.Execute("screensee", …)` 走完一整圈。
|
||
//
|
||
// 生产症状(2026-10-05 13:51):
|
||
//
|
||
// {"description":"屏幕截图视觉描述失败: api error 403: …"} ← status=ok
|
||
//
|
||
// 修好后应当是:err != nil,且结果里**没有** description 字段。
|
||
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 生产上真实发生过)
|
||
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
|
||
return "", errFake403
|
||
})
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||
defer srv.Close()
|
||
cli := dialTestWS(t, srv.URL, token)
|
||
defer cli.close()
|
||
|
||
// 设备必须先 hello+bind 才会进 registry(否则 Execute 报「设备不存在」)
|
||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"vf-dev","name":"视觉失败机","kind":"computer","caps":["screensee"]}}`))
|
||
cli.readHelloAckAndBind(t, token)
|
||
|
||
go func() {
|
||
for {
|
||
op, payload, err := cli.readMsg()
|
||
if err != nil || op != 0x1 {
|
||
return
|
||
}
|
||
var msg map[string]interface{}
|
||
if json.Unmarshal(payload, &msg) != nil {
|
||
continue
|
||
}
|
||
if msg["op"] == "cmd" && msg["command"] == "screensee" {
|
||
reqID, _ := msg["req_id"].(string)
|
||
img := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(makeJPEG(t, 64, 48))
|
||
cli.sendText(mustJSON(map[string]interface{}{
|
||
"op": "cmd_result", "req_id": reqID, "device_id": "vf-dev",
|
||
"status": "ok", "output": img,
|
||
}))
|
||
}
|
||
}
|
||
}()
|
||
|
||
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vf-dev", "auto_describe": true})
|
||
|
||
// ★ 必须报错
|
||
if err == nil {
|
||
t.Fatalf("视觉描述失败必须以 error 上抛,实际 res=%v —— "+
|
||
"渲染成 description 会让 agent 以为拿到了结果并反复重试", res)
|
||
}
|
||
// ★ 错误信息要保留上游原文(那是唯一诊断线索)
|
||
if !strings.Contains(err.Error(), "403") {
|
||
t.Errorf("错误信息应保留上游原文,实际: %v", err)
|
||
}
|
||
// ★ 结果里绝不能有 description 字段
|
||
if m, ok := res.(map[string]interface{}); ok {
|
||
if _, has := m["description"]; has {
|
||
t.Errorf("失败时不得返回 description 字段(那是本次要修的缺陷),实际: %v", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestScreenseE2E_SuccessReturnsDescription 钉住成功路径没被改坏。
|
||
func TestScreenseE2E_SuccessReturnsDescription(t *testing.T) {
|
||
reg := NewRegistry()
|
||
token := "tk-visual-ok"
|
||
reg.SetAcceptToken(func(p string) bool { return p == token })
|
||
|
||
dev := &devicectlDevice{reg: reg}
|
||
var gotLen int
|
||
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
|
||
gotLen = len(dataURL)
|
||
return "屏幕上是一个终端窗口", nil
|
||
})
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||
defer srv.Close()
|
||
cli := dialTestWS(t, srv.URL, token)
|
||
defer cli.close()
|
||
|
||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"vo-dev","name":"视觉成功机","kind":"computer","caps":["screensee"]}}`))
|
||
cli.readHelloAckAndBind(t, token)
|
||
|
||
go func() {
|
||
for {
|
||
op, payload, err := cli.readMsg()
|
||
if err != nil || op != 0x1 {
|
||
return
|
||
}
|
||
var msg map[string]interface{}
|
||
if json.Unmarshal(payload, &msg) != nil {
|
||
continue
|
||
}
|
||
if msg["op"] == "cmd" && msg["command"] == "screensee" {
|
||
reqID, _ := msg["req_id"].(string)
|
||
img := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(makeJPEG(t, 64, 48))
|
||
cli.sendText(mustJSON(map[string]interface{}{
|
||
"op": "cmd_result", "req_id": reqID, "device_id": "vo-dev",
|
||
"status": "ok", "output": img,
|
||
}))
|
||
}
|
||
}
|
||
}()
|
||
|
||
// ★★ 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)
|
||
}
|
||
m, ok := res.(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("返回类型变了: %T", res)
|
||
}
|
||
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("auto_describe 下 handler 应收到 dataURL")
|
||
}
|
||
}
|
||
|
||
// TestIsModelNotAllowed 钉住「模型名不被接受」的识别。
|
||
//
|
||
// ★ 这是 2026-10-05 生产那条 403 的判定入口:
|
||
//
|
||
// model "gpt-5.6-sol" is not allowed for this key
|
||
//
|
||
// 根因不是「模型不支持图」,而是**那个 key 的 model scope 只有 AUTO**
|
||
// (/etc/llmsproxy/config.yaml 的 keys[homeagent].models = [AUTO])。
|
||
// 而同一 key + base_url 下 model=AUTO 实测完全能看图 ⇒ 该回退而不是报错。
|
||
func TestIsModelNotAllowed(t *testing.T) {
|
||
// llmsproxy 的三种原文(internal/gateway/chat.go)
|
||
yes := []string{
|
||
`model "gpt-5.6-sol" is not allowed for this key`,
|
||
`{"error":{"type":"model_not_allowed","message":"model \"x\" is not allowed for this key"}}`,
|
||
`model "x" is not in this key's model scope, so it cannot use AUTO`,
|
||
}
|
||
for _, s := range yes {
|
||
if !isModelNotAllowed(errors.New(s)) {
|
||
t.Errorf("应识别为「模型名不被接受」: %s", s)
|
||
}
|
||
}
|
||
// ★ 必须**不**把其它错误误判成这一类 —— 那会触发错误的 AUTO 回退。
|
||
no := []string{
|
||
`invalid gateway api key`,
|
||
`api error 401: Unauthorized`,
|
||
`read: connection reset by peer`,
|
||
`context deadline exceeded`,
|
||
``, // nil 情形另行覆盖
|
||
}
|
||
for _, s := range no {
|
||
if isModelNotAllowed(errors.New(s)) {
|
||
t.Errorf("不应被识别为「模型名不被接受」(会误触发 AUTO 回退): %s", s)
|
||
}
|
||
}
|
||
if isModelNotAllowed(nil) {
|
||
t.Error("nil 不应被判为模型名问题")
|
||
}
|
||
}
|