Files
HomeAgent/internal/plugins/multimodal/plugin.go
JianFeeeee 9f7d91c219 refactor(memory)!: 删除 CAS 包与第二套召回(块 union 化第四阶段:收尾)
块 union 化前三阶段已让块自带负载、回填存量字节。本提交拆掉 CAS 本身:
`internal/memory/media/`(2100 行)+ `QueryMediaScored`(第二套召回)
+ `media_refs`/`ref_count` 引用账本 + `internal/sdk/media.go`(MediaAPI)
+ `SDKConfig.Media` / `SetMediaStore` 接线。删完 CAS 才真正不存在。

## 为何可删(不是"先删了再说")

- **MediaAPI 在生产早已是死的**:`SDKConfig.Media` 自 86572d13 起从未被设置,
  `sdk.Media()` 恒为 nil,插件 digest 路径一直走"内容寻址库不可用"分支。
  删的是死代码,不是活路径。
- **第二套召回确无调用方**:`QueryMediaScored` 仅被 media 包自身的测试引用,
  跨包引用为 0(块召回走 `RecallBlocks`)。
- **heldMediaDigests 补丁**(crossmodal 靠它防召回"无处可归的媒体")
  已在 86572d13 删除 —— 两套存储才需要的那种补丁,随存储一起没了。

## 替代路径:块引用取代 digest

插件取媒体从 `sdk.Media().Get(digest)` 改为 `sdk.Memory().MediaBytes(ref)`:

- **新增 `MemoryAPI.MediaBytes(ref) (data, mime, ok, err)`**(内置接口,非 pubsdk):
  按块引用(完整块 ID 或唯一前缀)取字节;`ok=false` 而非 error ——
  "这个引用没有媒体"是调用方能处理的常态。
- **seemedia / multimodal**:`digest` 参数语义改为**媒体块引用**,工具描述同步。
  path/url 两条来源不变(生产实际只用这两条)。
- **webui 知识上传**:媒体字节不再 Put 进 CAS 再拿 digest,而是作为
  `KnowledgeMediaRef.Data` 随引用携带,由 knowledge.Write 落进条目目录
  (与 content.md 同生共灭,Remove 的 RemoveAll 天然清掉)。
- **回填命令去依赖**:cmd/homed-media-backfill 不再 import 待删的 media 包,
  改为直接读**冻结的历史布局** blobs/<d[:2]>/<d[2:]> 并自校验 sha256。
  依赖待删代码的迁移工具会在删包那刻失效 —— 迁移必须比被迁移的东西活得久。

## 判据(全部做过变异测试)

- `TestMemoryBytes_ByBlockRef`:完整 ID / 唯一前缀可取回且逐字节一致;
  不存在→ok=false;过短前缀(歧义)→ok=false;无负载块→ok=false;空引用不 panic。
  变异(不校验 ok,把不存在当有)当场被 3 条断言抓住。
- `TestE2EMediaBytesAgainstBackfilledDB`(-tags e2e,读真回填库):
  10/10 块经 MediaBytes 取回的字节与块 data 的 sha256 完全一致。
- `TestKnowledgeUploadedMediaRetrievable` 重写为**条目目录自带字节**判据
  (不再是"向存储按 digest 读回");`TestKnowledgeUploadMediaNeedsNoStore`
  改为"上传无外部存储依赖也必须成功"(旧的 503 语义已随 CAS 消失)。

## 静态确认
`QueryMediaScored` / `internal/memory/media` import / `MediaAPI` /
`sdk.Media()` / `SetMediaStore` / `heldMediaDigests` 全仓均为 0 处
(仅剩 medialoop.go 一句历史注释)。

回归:memory / sdk / agent-core / knowledge / plugin / webui / seemedia /
multimodal 八包全绿;全仓生产编译 GREEN。
2026-10-12 16:52:43 +08:00

401 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package multimodal 是多模态工具的内置插件实现。
//
// # 为什么从内核搬出来(2026-10-05/06)
//
// `ocr_image` / `describe_image` / `transcribe_audio` 原先是内核
// `buildToolDefs()` 里硬编码的内置条件工具。两个问题:
//
// 1. **违反本仓第一原则**。README 写着「内核职责限定在 LLM 编排、记忆管理与
// 知识检索;所有 IO 能力由插件实现」,而「文件读取」正在 IO 列表里。
// 三个工具读本地文件(path 参数)却住在**内核进程内**——无沙箱、无能力面、
// 无审计。这是整个 `internal/plugin/proc/capability.go` 那套权限梯度
// 存在的意义所要防止的事。
// 2. **名不副实**。`ocr_image` 里没有任何 OCR 引擎,它只是「换个 prompt
// 再问 VLM 一次」(真 OCR 在 internal/nlp 的 ONNX,没接进来)。
// 工具名承诺的能力大于实现,模型据此误判精度。
//
// # 搬走之后内核还剩什么
//
// **input 通路留在内核**:`processInput` 处理用户直接发来的图/音时,
// 仍由内核把 media block 拼进本轮 LLM 请求(那是「输入即上下文」的一部分,
// 不是工具调用)。留在内核的是 `pendingMedia` 那个 map。
//
// # 本插件如何拿到媒体(不依赖内核的 pendingMedia)
//
// 两条来源,都不经内核:
//
// ① **path**:设备/工具回传的媒体已被 remotedevice 落盘(如 screensee 的
// file 字段),插件直接读文件。
// ② **块引用**:用户直接上传的媒体,内核在 `processInput` 里已落成记忆块
// (内容派生 ID)。插件用 `sdk.Memory().MediaBytes(ref)` 取回字节 ——
// 比在内核与插件之间传 base64 干净得多(内核那份 pendingMedia 可以退场)。
package multimodal
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
func init() {
plugin.RegisterPluginMeta("multimodal", "多模态工具", "Multimodal Tools")
plugin.RegisterFactory("multimodal", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
// Plugin 提供 describe_image / transcribe_audio / ocr_image。
type Plugin struct {
name string
sdk *sdk.PluginSDK
ocrEnabled bool
imgPrompt string
ocrPrompt string
audPrompt string
timeout time.Duration
}
// New 构造插件。
func New(name string) *Plugin { return &Plugin{name: name} }
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// ---- 设置 ----
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "ocr_enabled", Default: "true", Type: "bool",
DisplayName: "启用 OCR 工具",
Description: "★注意:本工具**不是专用 OCR 引擎**,而是「用视觉模型读图中的文字」。" +
"高密度小字/表格可能不准;关闭后 ocr_image 不出现在工具表里。",
Category: "multimodal",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "describe_prompt", Type: "text",
DisplayName: "图片描述提示词",
Description: "describe_image 使用的提示词。",
Category: "multimodal",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "ocr_prompt", Type: "text",
DisplayName: "读字提示词",
Description: "ocr_image 使用的提示词。",
Category: "multimodal",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "audio_prompt", Type: "text",
DisplayName: "音频转写提示词",
Description: "transcribe_audio 使用的提示词。",
Category: "multimodal",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout_seconds", Default: "120", Type: "int",
DisplayName: "多模态调用超时(秒)",
Description: "单次多模态请求的上限。",
Category: "multimodal",
})
p.ocrEnabled = true
p.timeout = 120 * time.Second
p.loadSettings()
if err := s.RegisterTool("describe_image", sdk.ToolDef{
Name: "describe_image",
Description: "描述一张图片的内容。可处理本轮用户上传的图片," +
"也可用 path 读设备/工具回传的图片文件(如 screensee 的截图)。",
Parameters: p.commonParams(),
ParallelSafe: false, // 会切 LLM 源(provider 参数),非并发安全
}, p.handleDescribeImage); err != nil {
return err
}
if err := s.RegisterTool("transcribe_audio", sdk.ToolDef{
Name: "transcribe_audio",
Description: "把一段音频转写为文字。可处理本轮上传的音频,也可用 path 读文件。",
Parameters: p.commonParams(),
ParallelSafe: false,
}, p.handleTranscribeAudio); err != nil {
return err
}
// ocr_image 的注册**受开关控制**(沿用内核原语义)。
// 不启用时它不出现在工具表里 —— 这是刻意的:工具在表里却不可用,
// 比不出现更糟(模型会反复调一个注定失败的工具)。
if p.ocrEnabled {
if err := s.RegisterTool("ocr_image", p.ocrToolDef(), p.handleOCRImage); err != nil {
return err
}
}
return nil
}
func (p *Plugin) Stop() error { return nil }
// commonParams 是三个工具共用的参数形态。
func (p *Plugin) commonParams() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "可选:媒体文件路径。★处理设备/工具回传的媒体时必须传" +
"(screensee / camerasue 会回传 file 路径);不传则处理本轮用户上传的媒体。",
},
"digest": map[string]interface{}{
"type": "string",
"description": "可选:媒体在记忆里的**块引用**(块 ID 或其唯一前缀)。与 path 二选一。",
},
"provider": map[string]interface{}{
"type": "string",
"description": "可选:用于该次调用的 LLM 源名称,不填则用当前源",
},
},
}
}
func (p *Plugin) ocrToolDef() sdk.ToolDef {
params := p.commonParams()
params["properties"].(map[string]interface{})["language"] = map[string]interface{}{
"type": "string",
"description": "可选:OCR 语言(如 chi_sim+eng),默认自动",
}
return sdk.ToolDef{
Name: "ocr_image",
Description: "读出图片中的文字。★这是**用视觉模型读字**,不是专用 OCR 引擎 —— " +
"高密度小字/表格可能不准,重要内容建议配合 describe_image 交叉验证。",
Parameters: params,
ParallelSafe: false,
}
}
func (p *Plugin) loadSettings() {
s := p.sdk.Settings()
if s == nil {
return
}
if v, _ := s.Get("ocr_enabled"); v != nil {
p.ocrEnabled = truthy(v)
}
if v, _ := s.Get("timeout_seconds"); v != nil {
if n := toInt(v); n > 0 {
p.timeout = time.Duration(n) * time.Second
}
}
if v, _ := s.Get("describe_prompt"); v != nil && strings.TrimSpace(fmt.Sprint(v)) != "" {
p.imgPrompt = fmt.Sprint(v)
}
if v, _ := s.Get("ocr_prompt"); v != nil && strings.TrimSpace(fmt.Sprint(v)) != "" {
p.ocrPrompt = fmt.Sprint(v)
}
if v, _ := s.Get("audio_prompt"); v != nil && strings.TrimSpace(fmt.Sprint(v)) != "" {
p.audPrompt = fmt.Sprint(v)
}
}
func truthy(v interface{}) bool {
switch x := v.(type) {
case bool:
return x
case string:
return x == "true" || x == "1" || x == "yes"
case float64:
return x != 0
}
return false
}
func toInt(v interface{}) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case string:
n := 0
for _, c := range x {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}
return 0
}
// ---- 三个 handler ----
func (p *Plugin) handleDescribeImage(args map[string]interface{}) (interface{}, error) {
return p.describe(args, p.imgPromptOrDefault(), "图片描述", 2048, "image_url")
}
func (p *Plugin) handleOCRImage(args map[string]interface{}) (interface{}, error) {
return p.describe(args, p.ocrPromptOrDefault(), "读字结果", 4096, "image_url")
}
func (p *Plugin) handleTranscribeAudio(args map[string]interface{}) (interface{}, error) {
return p.describe(args, p.audPromptOrDefault(), "音频转写", 2048, "audio_url")
}
func (p *Plugin) imgPromptOrDefault() string {
if p.imgPrompt != "" {
return p.imgPrompt
}
return "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。"
}
func (p *Plugin) ocrPromptOrDefault() string {
if p.ocrPrompt != "" {
return p.ocrPrompt
}
return "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。"
}
func (p *Plugin) audPromptOrDefault() string {
if p.audPrompt != "" {
return p.audPrompt
}
return "请把这段音频转写为文字。"
}
// describe 解析媒体来源 → 切源(若指定)→ 发一次多模态请求。
//
// ★ 失败一律以 **error** 上抛,不渲染成普通文本。
//
// 这是从内核搬来时保留的纪律(见 internal/plugins/remotedevice 的
// describeScreen 同类修复):失败与成功同形会让 agent 反复重试。
func (p *Plugin) describe(args map[string]interface{}, prompt, prefix string,
maxTokens int, blockType string) (interface{}, error) {
url, err := p.resolveMedia(args, blockType)
if err != nil {
return nil, err
}
llm := p.sdk.LLM()
if llm == nil {
return nil, fmt.Errorf("LLM 不可用")
}
// 指定源:临时切换,用完恢复。切源失败**不降级**(否则「另一个源的结果」
// 会挂在指定源名下)。
provider, _ := args["provider"].(string)
if provider != "" {
prev := llm.CurrentSource()
if e := llm.SetSource(provider); e != nil {
return nil, fmt.Errorf("切换到指定源 %s 失败: %w", provider, e)
} else if prev != "" {
defer func() { _ = llm.SetSource(prev) }()
}
}
used := llm.CurrentSource()
msg := sdk.LLMMessage{
Role: "user",
Blocks: []sdk.LLMContentBlock{
{Type: "text", Text: prompt},
},
}
if blockType == "image_url" {
msg.Blocks = append(msg.Blocks, sdk.LLMContentBlock{Type: "image_url", ImageURL: url})
} else {
msg.Blocks = append(msg.Blocks, sdk.LLMContentBlock{Type: "audio_url", ImageURL: url})
}
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
resp, err := llm.Chat(ctx, &sdk.LLMCompletionRequest{
Messages: []sdk.LLMMessage{msg},
MaxTokens: maxTokens,
})
if err != nil {
return nil, fmt.Errorf("%s失败(视觉/音频源 %s): %w", prefix, used, err)
}
if resp == nil || strings.TrimSpace(resp.Content) == "" {
return nil, fmt.Errorf("%s失败(视觉/音频源 %s): 模型返回空内容", prefix, used)
}
return map[string]interface{}{
"description": resp.Content,
"source": used,
}, nil
}
// resolveMedia 按 path → 块引用 的顺序解析出 data URL。
func (p *Plugin) resolveMedia(args map[string]interface{}, kind string) (string, error) {
path, _ := args["path"].(string)
ref, _ := args["digest"].(string)
switch {
case strings.TrimSpace(path) != "":
b, err := os.ReadFile(strings.TrimSpace(path))
if err != nil {
return "", fmt.Errorf("读取文件失败 %s: %w", path, err)
}
if len(b) == 0 {
return "", fmt.Errorf("文件为空: %s", path)
}
mime := mimeByExt(path)
if mime == "" {
mime = "image/png"
if kind == "audio_url" {
mime = "audio/wav"
}
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), nil
case strings.TrimSpace(ref) != "":
// ★ CAS 淘汰:digest 参数现收**媒体块引用**,由记忆层按块取字节。
if p.sdk.Memory() == nil {
return "", fmt.Errorf("记忆系统不可用,无法按块引用取媒体")
}
b, mime, ok, err := p.sdk.Memory().MediaBytes(strings.TrimSpace(ref))
if err != nil {
return "", fmt.Errorf("按块引用取媒体失败 %s: %w", ref, err)
}
if !ok {
return "", fmt.Errorf("媒体块引用 %s 无对应块或无负载", ref)
}
if mime == "" {
mime = "image/png"
if kind == "audio_url" {
mime = "audio/wav"
}
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), nil
}
// ★ 两条都没有 ⇒ 明确报错,并说清怎么办。
return "", fmt.Errorf("没有可处理的媒体。请传 path(设备/工具回传的媒体," +
"如 screensee 返回的 file 路径)或 digest(记忆里的媒体块引用);" +
"本轮用户直接上传的图片/音频请直接用它们,无需本工具")
}
func mimeByExt(p string) string {
switch strings.ToLower(filepath.Ext(p)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
case ".wav":
return "audio/wav"
case ".mp3":
return "audio/mpeg"
case ".m4a", ".aac":
return "audio/aac"
}
return ""
}