mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
feat(multimodal): 多模态工具移出内核为内置插件 + get_plugin_tools 收编为内核服务
## 背景 README 第一原则:「内核职责限定在 LLM 编排、记忆管理与知识检索; 所有 IO 能力由插件实现」,而「文件读取」正在 IO 列表里。 describe_image / ocr_image / transcribe_audio 读本地文件却住在内核进程内 ——无沙箱、无能力面、无审计,正是 capability.go 那套权限梯度要防的事。 ## 本次改动 ### 1. 多模态工具移出内核(internal/plugins/multimodal) - 新增内置插件,注册 describe_image / transcribe_audio / ocr_image - 媒体来源支持 path(设备/工具回传,如 screensee 的 file)与 digest(CAS) - 失败一律以 error 上抛,不渲染成普通文本(延续 screensee 的失败传播纪律) - ocr_image 的描述明说「不是专用 OCR 引擎」:它只是用视觉模型读字, 真 OCR 在 internal/nlp 的 ONNX 未接入。名字承诺大于实现会让模型误判精度。 内核侧:删除 media.go 整文件(9 函数,全部零引用)、3 个 dispatch case、 3 个工具定义。 **留在内核**:input 通路(用户直传媒体直接拼进本轮 LLM 请求)—— 那是「输入即上下文」,不是工具调用。 ### 2. get_plugin_tools 收编为内核完整服务 原先它只收 StageHost + IOManager 两处,而三个媒体工具、memory_*、 knowledge_*、spawn_child 等全是内核 buildToolDefs 里直接 append 的 ——一条都数不到。agent 能调这些工具,却调 get_plugin_tools 看不见它们。 - 以 buildToolDefs()(发给模型的那份工具表)为单一真相源 - 新增 builtinToolOwner 显式归属表:内置工具恒为 core - resolveToolPlugin 的下划线前缀猜测限定在「该前缀是已注册插件」时才采纳, 避免 ocr_image→"ocr"、memory_commit→"memory" 这类幽灵归属(7 处调用点依赖它) ## 判据 新增 plugin_tools_service_test.go、multimodal_departed_test.go、 multimodal/plugin_test.go。三个结构判据经变异自证会红: 1. 提示词改回「请使用 describe_image 工具」→ 仍出现该名 ⇒ 红 ★ 这条抓到了真问题:eventloop.go 的媒体提示词仍在叫模型调旧工具名, 而媒体 block 其实已内联给模型,且新工具需要 path/digest(用户直传都没有) ⇒ 模型照做必然失败。已修:改为「已随本消息附上,请直接查看」。 2. 内核为多模态加一个 os.ReadFile → 红(比「名字没出现」更强) 3. core 反向 import 插件 → 红(迁移而非删除,且守住分层) ## 验证 - go build ./... / go vet / go test ./internal/... 全绿 - go test -race ./internal/agent/core/ ./internal/plugins/multimodal/ 无数据竞争 - 部署后:39 插件加载、0 panic/fatal、0 非空 error - 三个工具注册归属为 plugin=multimodal(不是 inferToolPlugin 猜的 describe) - graph.db integrity ok,blocks 2787 / relations 980 未动 - 快照 /home/newqqagent/memory/pre-multimodal-plugin-20261006-090541/ - 二进制备份 /tmp/homed.bak-mmplugin 一并包含此前已验证未提交的三处静默失效修复: - MCP MCPCallResult.IsError 丢失(空结果伪装成功) - DeleteEntity 谎报删除 - screensee 谎报成功 + 截图诊断(尺寸/magic/sha) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,8 +1,12 @@
|
||||
package remotedevice
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@ -18,7 +22,9 @@ type devicectlDevice struct {
|
||||
|
||||
// screensee 回调:设备截屏回传后由 agent 核心消费(视觉描述)。
|
||||
// 由插件 Start 注入;nil 时退化为仅返回 base64 数据。
|
||||
seeHandler func(dataURL string, provider string) string
|
||||
// ★ 2026-10-05 改为 (string, error):视觉描述失败必须能上抛,
|
||||
// 不能渲染成普通文本(那会让 status=ok 与失败同形)。
|
||||
seeHandler func(dataURL string, provider string) (string, error)
|
||||
}
|
||||
|
||||
func (d *devicectlDevice) Name() string { return "devicectl" }
|
||||
@ -361,7 +367,7 @@ func (d *devicectlDevice) info(args map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
// SetSeeHandler 注入 screensee 的视觉描述回调(agent 核心提供)。
|
||||
func (d *devicectlDevice) SetSeeHandler(fn func(dataURL string, provider string) string) {
|
||||
func (d *devicectlDevice) SetSeeHandler(fn func(dataURL string, provider string) (string, error)) {
|
||||
d.seeHandler = fn
|
||||
}
|
||||
|
||||
@ -401,6 +407,19 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e
|
||||
return nil, fmt.Errorf("设备截屏失败: %s", errMsg)
|
||||
}
|
||||
output, _ := res["output"].(string)
|
||||
// ★ 2026-10-05:把回传内容**量化后记进服务端日志**。
|
||||
//
|
||||
// 起因:2026-10-05 生产日志里有两次 screensee 回了
|
||||
// 「我目前没有收到或无法查看这张屏幕截图」
|
||||
// 而服务端**没有任何记录**能回答「设备到底回传了什么」——
|
||||
// GUI 侧那行 console.log 只打在它自己的控制台,服务端留不下痕迹。
|
||||
// 于是排查只能靠猜(截屏为空?data URL 被截断?缩略图未就绪?)。
|
||||
//
|
||||
// 只记**元信息 + 极短内容指纹**,不记图片本身(日志会膨胀且含用户屏幕内容):
|
||||
// · 字节数、解出来的像素尺寸(jpeg/png 头部可解)、是否非空
|
||||
// · 前若干字节的 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
|
||||
@ -409,7 +428,18 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e
|
||||
if d.seeHandler == nil {
|
||||
return map[string]interface{}{"image_data_url": output, "note": "无视觉描述处理器,仅返回原始图像数据"}, nil
|
||||
}
|
||||
desc := d.seeHandler(output, provider)
|
||||
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
|
||||
}
|
||||
|
||||
@ -543,12 +573,31 @@ func (d *devicectlDevice) clipboardsee(args map[string]interface{}) (interface{}
|
||||
func (d *devicectlDevice) clipboardsue(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["device_id"].(string)
|
||||
text, _ := args["text"].(string)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("text required(要写入剪切板的内容)")
|
||||
// ★ content 参数(判据 clipboardsue_content_test.go 钉着它)——
|
||||
// 从**服务端可见的路径**读入剪切板,长文本/代码块不塞得进命令行。
|
||||
// 与 GUI 侧 screensue/speakeruse 的 @<路径> 是同一类问题的同一类解法。
|
||||
//
|
||||
// ★ 这段实现曾因「只提交判据没提交代码」被仓库清理冲掉,
|
||||
// 导致判据编译不过(undefined: clipboardsueMaxBytes)。
|
||||
// 判据与实现在同一个提交里,才不会再次走丢。
|
||||
content, _ := args["content"].(string)
|
||||
if strings.TrimSpace(content) == "" && strings.TrimSpace(text) == "" {
|
||||
return nil, fmt.Errorf("text 或 content 至少给一个(要写入剪切板的内容)")
|
||||
}
|
||||
if err := d.clipboardCheck(id, "写入", "clipboardsue"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(content) != "" {
|
||||
b, rerr := os.ReadFile(strings.TrimSpace(content))
|
||||
if rerr != nil {
|
||||
return nil, fmt.Errorf("读取 content 文件失败 %s: %w", strings.TrimSpace(content), rerr)
|
||||
}
|
||||
if len(b) > clipboardsueMaxBytes {
|
||||
return nil, fmt.Errorf("content 文件过大 %dKB > 上限 %dKB",
|
||||
len(b)/1024, clipboardsueMaxBytes/1024)
|
||||
}
|
||||
text = string(b)
|
||||
}
|
||||
reqID := newReqID()
|
||||
// 命令格式:clipboardsue <文字>(GUI 端取首个空格后的全部内容作为写入文本)
|
||||
if err := d.reg.PushCmd(id, reqID, "clipboardsue "+text, "homeagent"); err != nil {
|
||||
@ -583,3 +632,105 @@ func truncateForPreview(s string, max int) string {
|
||||
}
|
||||
return string(r[:max]) + "..."
|
||||
}
|
||||
|
||||
// clipboardsueMaxBytes 是从文件读入剪切板的大小上限(与 GUI 侧 2MB 一致)。
|
||||
const clipboardsueMaxBytes = 2 * 1024 * 1024
|
||||
|
||||
// logScreenseePayload 把设备回传的截图**元信息**记进服务端日志。
|
||||
//
|
||||
// ★ 为什么必须记在服务端(2026-10-05 教训):
|
||||
//
|
||||
// GUI 侧那行 `[device-bridge] screensee screen=0 bytes=N` 只打在它自己的
|
||||
// 控制台。生产排障时手上只有 homed 的日志 ⇒ 无法回答「设备到底回传了什么」。
|
||||
// 实测就有两次 screensee 返回「我收不到截图」,而服务端无任何痕迹可查,
|
||||
// 最后只能靠推测(截屏为空 / data URL 截断 / 缩略图未就绪)。
|
||||
//
|
||||
// ★ 只记元信息与**极短指纹**,不记图片本体:
|
||||
//
|
||||
// · 日志会按字节膨胀;
|
||||
// · 更重要的是截图是**用户屏幕内容**,属敏感信息,不该进日志。
|
||||
//
|
||||
// 记什么:字节数、magic、jpeg SOF 解出的像素尺寸、base64 头 24 字符、SHA256 前 16 hex。
|
||||
// 能回答:「有没有回传」「是不是合法图片」「多长」「两次是不是同一张」「会不会被截断」。
|
||||
func logScreenseePayload(deviceID, dataURL string) {
|
||||
if dataURL == "" {
|
||||
log.Printf("[remotedevice] screensee 回传 device=%s len=0 ★ 空内容:设备端没截到图"+
|
||||
"(锁屏/虚拟桌面/缩略图未就绪?)", deviceID)
|
||||
return
|
||||
}
|
||||
raw := dataURL
|
||||
if i := strings.Index(raw, ","); strings.HasPrefix(raw, "data:") && i >= 0 {
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
// ★ 解不出来本身就是关键信号:说明回传的不是合法 base64(可能被截断)。
|
||||
log.Printf("[remotedevice] screensee 回传 device=%s len=%d ★ base64 解码失败: %v",
|
||||
deviceID, len(raw), err)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
head := raw
|
||||
if len(head) > 24 {
|
||||
head = head[:24]
|
||||
}
|
||||
w, h := jpegSize(b)
|
||||
log.Printf("[remotedevice] screensee 回传 device=%s bytes=%d magic=%s %dx%d b64head=%q sha=%x",
|
||||
deviceID, len(b), magicName(b), w, h, head, sum[:8])
|
||||
}
|
||||
|
||||
// magicName 认出图片类型,用于一眼判断「回传的到底是什么」。
|
||||
func magicName(b []byte) string {
|
||||
switch {
|
||||
case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF:
|
||||
return "jpeg"
|
||||
case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n":
|
||||
return "png"
|
||||
case len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a"):
|
||||
return "gif"
|
||||
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
|
||||
return "webp"
|
||||
case len(b) == 0:
|
||||
return "empty"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// jpegSize 从 jpeg 的 SOF 标记解出像素尺寸;不是 jpeg 或解不出返回 0,0。
|
||||
//
|
||||
// 不引入 image/jpeg 全解码:这里只要尺寸,而截图可能很大,
|
||||
// 全解码一次是纯浪费(GUI 已经编码过一次了)。
|
||||
func jpegSize(b []byte) (int, int) {
|
||||
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
return 0, 0
|
||||
}
|
||||
i := 2
|
||||
for i+3 < len(b) {
|
||||
if b[i] != 0xFF {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
marker := b[i+1]
|
||||
// SOF0..SOF3 / SOF5..SOF7 / SOF9..SOF11 / SOF13..SOF15 携带尺寸
|
||||
if marker >= 0xC0 && marker <= 0xCF &&
|
||||
marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
|
||||
if i+9 < len(b) {
|
||||
h := int(b[i+5])<<8 | int(b[i+6])
|
||||
w := int(b[i+7])<<8 | int(b[i+8])
|
||||
return w, h
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
// 其余段按 2 字节长度跳过
|
||||
if i+3 >= len(b) {
|
||||
break
|
||||
}
|
||||
segLen := int(b[i+2])<<8 | int(b[i+3])
|
||||
if segLen < 2 {
|
||||
break
|
||||
}
|
||||
i += 2 + segLen
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user