mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
feat(seemedia): 多模态直视插件——让模型一手看图/看视频/听音频 (#6)
* feat(seemedia): 新增多模态直视插件,让模型一手看图/看视频/听音频
## 动机
现有 multimodal(describe_image / transcribe_audio / ocr_image)走的是**二手**路:
插件自己起一次 VLM 子调用,把结果**文字**喂回主模型。省 token、能入记忆正文、
主模型非视觉也能用,但模型看到的是「别人转述的描述」,细节丢失不可逆。
缺的是**一手**路:让模型主动把原始像素/帧/音频投给**自己**去判读。本插件补上。
## 工具(三模态,用户决策 image+video+audio)
- `see_image`:path/digest/url → image_url 块,经 SetToolBlocks 直投主模型
- `see_video`:ffmpeg 抽帧(默认 6 帧,上限 10,对齐内核 modalfallback 约束)
→ 多张 image_url 块一起投;探时长成功走均匀定位抽帧,失败退单遍 thumbnail
- `hear_audio`:path/digest/url → audio_url 块直投
三者都 `ParallelSafe:false`:SetToolBlocks 写的是「当前 call」的块,并发会串台。
## 与 multimodal 并存(用户决策 coexist)
职责分工,不互相取代:
- 要省 token / 要把结论写进记忆 / 主模型不支持该模态 → describe_image(二手文字)
- 要模型亲自判读细节(看清截图、逐帧看操作) → see_*(一手原始媒体)
## 媒体来源(用户决策 path+digest+url 全支持)
- path:设备/工具回传的落盘文件(local_screensee、browser_screenshot、mc 截图…)
- digest:用户上传已入 CAS 的媒体,sdk.Media().Get 取回,不在内核↔插件间传 base64
- url:http(s) 直链;图/音频原样透传(不下载、不入 CAS),视频交 ffmpeg 原生读 http
## 直接进记忆(记忆载体已块化,天然打通)
seemedia 经 SetToolBlocks 注入的块,内核 task.go captureBlockMedia 处**无条件**
落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退,媒体本体都该
进记忆」)。所以一手看到的图/帧**同时**进了记忆,之后能按多模态向量被
doc_query / memory_recall 召回。本插件不写任何入库代码,复用既有块化链路。
★ 边界(刻意取舍):captureBlockMedia 只对 data URL 入库,http(s) URL 被跳过
(不下载、不入记忆、不算向量,规避 SSRF/超时)。即 path/digest 来源可召回,
url 来源只是模型当场看到但不进记忆。要让 http 媒体长期可召回,先落盘再传 path。
## 形态:内置插件(用户决策 builtin)
与 multimodal 并排 internal/plugins/seemedia,经 all.go init() 注册。代价是延续
「内核进程内读任意本地文件」这个 a02d8b00 本想消除的张力——但 multimodal 已是
内置且同样读 path,保持一致比单独走子进程沙箱更不易让人困惑;日后收紧应两者同搬。
## 判据(可杀变异,实测)
plugin_test.go:
- url 透传且拒 file://(去掉 isHTTPURL 校验 → FAIL)
- path 优先于 digest、url 优先于 path,且 path 命中时不碰 CAS
- 无来源报错且信息可操作(指明 path/digest/url)
- 文件错误带路径、空文件明说「空」
- clampFrames 把帧数夹到 [1,10]、非正回默认(超限不夹 → FAIL)
- 三工具不得声明 ParallelSafe:true(防 SetToolBlocks 串台)
9/9 单测通过;本机 ffmpeg/ffprobe 已就位;全插件 + homed(-tags onnxruntime)
构建通过;internal/plugins 装配冒烟通过。
* test(seemedia): 补集成/压力/变异测试(16 判据,5 变异全杀)
上一个提交只带 9 个纯逻辑单测(来源解析、clamp、MIME)。抽帧是本插件唯一
真正驱动外部进程、且会并发的部分,必须真跑 ffmpeg 压一压:
- 真视频 → 请求 N 帧就拿到 N 帧,每帧校验 PNG 魔数(1/3/6/10 四档)
- ffprobe 置空 → 走单遍兜底仍能拿到帧
- 损坏/非视频文件 → 如实报错,不 panic、不返半成品
- 已取消的 ctx → ffmpeg 必须立刻失败(取消传播真生效)
- 并发 16 路抽帧 → 不串台、不泄漏临时目录(前后计数比对)
- digest 视频 → 真 CAS 取字节落临时文件,cleanup 后必须删掉
- 静态判据:所有子进程必须 exec.CommandContext,不得裸 exec.Command
为什么超时判据从「计时」改成「已取消 ctx」:合成视频抽帧本来就快(~0.1s),
「1ns 超时」和「忽略超时」在计时上分不出——实测这种变异杀不掉,是判据设计
缺陷。改用已取消 ctx 的行为判据(CommandContext 在 ctx 已取消时 Start 即失败)
后,忽略 ctx 的变异被杀。
变异实测(5/5 全 FAIL,还原后全绿):
A clampFrames 超限不夹 → TestClampFrames FAIL
B url 分支去掉 isHTTPURL → TestResolveMediaURL_URLPassthrough FAIL
C cleanup 不删临时文件 → TestResolveVideoInput_DigestCleanup FAIL
D grabFrameAt 忽略 ctx → TestFrameGrabHonorsCanceledContext FAIL
E 抽帧数写死 1 → TestExtractFrames_RealVideoExactCount FAIL
16/16 通过;`-tags onnxruntime` 全量 homed 构建通过,三工具在册。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
241
internal/plugins/seemedia/integration_test.go
Normal file
241
internal/plugins/seemedia/integration_test.go
Normal file
@ -0,0 +1,241 @@
|
||||
package seemedia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 真跑 ffmpeg 的集成/压力/变异测试;无 ffmpeg 时自动跳过。
|
||||
//
|
||||
// 单测(plugin_test.go)钉纯逻辑(来源解析、clamp、MIME);这里钉真实行为:
|
||||
// - 抽帧真能从一段真视频拿到正确数量的合法 PNG
|
||||
// - 探不到时长时走单遍兜底仍能拿到帧
|
||||
// - 损坏输入如实报错、不 panic
|
||||
// - 已取消的 ctx 必须让 ffmpeg 立刻失败(取消传播真生效)
|
||||
// - 并发 16 路抽帧不串台、不泄漏临时目录
|
||||
// - digest 视频走真 CAS → 临时文件 → cleanup 删文件
|
||||
|
||||
// sdkWithVideo 用真 CAS(临时目录)把一段视频字节 Put 进去,返回带媒体面的
|
||||
// PluginSDK 与**真实 digest**(真实 CAS 的 key 是内容哈希,直接用它取回)。
|
||||
func sdkWithVideo(t *testing.T, raw []byte) (*sdk.PluginSDK, string) {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "seemedia-cas-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
store, err := media.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest, err := store.Put(raw, media.Item{MIME: "video/mp4", Tool: "test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sdk.New("seemedia", sdk.SDKConfig{Media: sdk.NewMedia(store)}), digest
|
||||
}
|
||||
|
||||
func mkTestVideo(t *testing.T, dur int) string {
|
||||
t.Helper()
|
||||
ff, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
t.Skip("无 ffmpeg,跳过集成测试")
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "clip.mp4")
|
||||
cmd := exec.Command(ff, "-nostdin", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", fmt.Sprintf("testsrc=duration=%d:size=320x240:rate=10", dur),
|
||||
"-pix_fmt", "yuv420p", out, "-y")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Skipf("ffmpeg 造测试视频失败(环境问题,跳过): %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newProbedPlugin(t *testing.T) *Plugin {
|
||||
t.Helper()
|
||||
p := &Plugin{videoFrames: defaultVideoFrames, timeout: 60 * time.Second}
|
||||
p.ffmpeg, _ = exec.LookPath("ffmpeg")
|
||||
p.ffprobe, _ = exec.LookPath("ffprobe")
|
||||
if p.ffmpeg == "" {
|
||||
t.Skip("无 ffmpeg,跳过集成测试")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func isPNG(b []byte) bool {
|
||||
return len(b) >= 8 &&
|
||||
b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G' &&
|
||||
b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A
|
||||
}
|
||||
|
||||
func countSeemediaTemp() int {
|
||||
frames, _ := filepath.Glob(filepath.Join(os.TempDir(), "seemedia-frames-*"))
|
||||
vids, _ := filepath.Glob(filepath.Join(os.TempDir(), "seemedia-*.video"))
|
||||
return len(frames) + len(vids)
|
||||
}
|
||||
|
||||
// ① 真视频 → 请求 N 帧就拿到 N 帧,且每帧都是合法 PNG。
|
||||
func TestExtractFrames_RealVideoExactCount(t *testing.T) {
|
||||
p := newProbedPlugin(t)
|
||||
video := mkTestVideo(t, 10)
|
||||
|
||||
for _, n := range []int{1, 3, 6, 10} {
|
||||
frames, err := p.extractFrames(video, n)
|
||||
if err != nil {
|
||||
t.Fatalf("n=%d 抽帧失败: %v", n, err)
|
||||
}
|
||||
if len(frames) != n {
|
||||
t.Errorf("请求 %d 帧,实得 %d 帧", n, len(frames))
|
||||
}
|
||||
for i, f := range frames {
|
||||
if !isPNG(f) {
|
||||
t.Errorf("n=%d 第 %d 帧不是合法 PNG(%d 字节)", n, i, len(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 探不到时长(ffprobe 置空)时走单遍兜底,仍能拿到帧。
|
||||
func TestExtractFrames_FallbackPathWorks(t *testing.T) {
|
||||
p := newProbedPlugin(t)
|
||||
video := mkTestVideo(t, 6)
|
||||
|
||||
p.ffprobe = "" // 强制走兜底:模拟探时长失败
|
||||
frames, err := p.extractFrames(video, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("兜底路径抽帧失败: %v", err)
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
t.Fatal("兜底路径一帧都没抽到")
|
||||
}
|
||||
for i, f := range frames {
|
||||
if !isPNG(f) {
|
||||
t.Errorf("兜底第 %d 帧不是 PNG", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 损坏/非视频文件:如实报错,不 panic、不返回半成品。
|
||||
func TestExtractFrames_CorruptInput(t *testing.T) {
|
||||
p := newProbedPlugin(t)
|
||||
bad := filepath.Join(t.TempDir(), "notvideo.mp4")
|
||||
if err := os.WriteFile(bad, []byte("this is definitely not a video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames, err := p.extractFrames(bad, 3)
|
||||
if err == nil && len(frames) > 0 {
|
||||
t.Errorf("损坏输入不应抽出帧,却得到 %d 帧", len(frames))
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 取消传播真生效:已取消的 ctx 必须让 ffmpeg 立刻失败,而不是照跑。
|
||||
//
|
||||
// 为什么不计时测 p.timeout:合成视频抽帧本来就快(~0.1s),「1ns 超时」和
|
||||
// 「忽略超时」在计时上分不出(此前实测这种变异未被杀,正是判据设计缺陷)。
|
||||
// 真正要防的是「完全不用 ctx → 无限挂」,用已取消 ctx 的行为判据来钉:
|
||||
// exec.CommandContext 在 ctx 已取消时 Start 即失败;换成 exec.Command 则照跑。
|
||||
func TestFrameGrabHonorsCanceledContext(t *testing.T) {
|
||||
p := newProbedPlugin(t)
|
||||
video := mkTestVideo(t, 4)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
|
||||
b, err := p.grabFrameAt(ctx, video, 1.0)
|
||||
if err == nil {
|
||||
t.Fatalf("已取消的 ctx 必须让抽帧失败,却成功返回 %d 字节(ffmpeg 未被 ctx 约束)", len(b))
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 压力:并发 16 路抽帧,各自独立临时目录,不串帧、不泄漏临时目录。
|
||||
func TestExtractFrames_ConcurrentNoLeak(t *testing.T) {
|
||||
p := newProbedPlugin(t)
|
||||
video := mkTestVideo(t, 8)
|
||||
|
||||
tmpBefore := countSeemediaTemp()
|
||||
|
||||
const workers = 16
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
n := (id % 10) + 1
|
||||
frames, err := p.extractFrames(video, n)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("worker %d: %w", id, err)
|
||||
return
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
errs <- fmt.Errorf("worker %d: 0 帧", id)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
tmpAfter := countSeemediaTemp()
|
||||
if tmpAfter > tmpBefore {
|
||||
t.Errorf("并发抽帧泄漏临时目录: before=%d after=%d", tmpBefore, tmpAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ digest 视频来源:真 CAS 取字节 → 临时文件 → 抽帧 → cleanup 删文件。
|
||||
func TestResolveVideoInput_DigestCleanup(t *testing.T) {
|
||||
p := newProbedPlugin(t)
|
||||
video := mkTestVideo(t, 4)
|
||||
raw, err := os.ReadFile(video)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, digest := sdkWithVideo(t, raw)
|
||||
p.sdk = s
|
||||
|
||||
input, cleanup, err := p.resolveVideoInput(map[string]interface{}{"digest": digest})
|
||||
if err != nil {
|
||||
t.Fatalf("digest 解析失败: %v", err)
|
||||
}
|
||||
if _, serr := os.Stat(input); serr != nil {
|
||||
t.Fatalf("临时视频文件应存在: %v", serr)
|
||||
}
|
||||
frames, err := p.extractFrames(input, 2)
|
||||
if err != nil || len(frames) == 0 {
|
||||
t.Errorf("从 digest 临时文件抽帧失败: err=%v frames=%d", err, len(frames))
|
||||
}
|
||||
cleanup()
|
||||
if _, serr := os.Stat(input); !os.IsNotExist(serr) {
|
||||
t.Errorf("cleanup 后临时文件应被删除,stat=%v", serr)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 静态判据:所有 ffmpeg/ffprobe 子进程必须用 exec.CommandContext。
|
||||
//
|
||||
// CommandContext 是「任何情况下都能被 ctx 中断」的契约;换成 exec.Command
|
||||
// 就失去取消能力。这条跨实现有效:谁把 CommandContext 改回 Command,谁就引入
|
||||
// 「网络流/坏文件上无限挂」的风险。
|
||||
func TestFrameCmdsUseCommandContext(t *testing.T) {
|
||||
src, err := os.ReadFile("plugin.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, line := range strings.Split(string(src), "\n") {
|
||||
if strings.Contains(line, "exec.Command(") && !strings.Contains(line, "exec.CommandContext(") {
|
||||
t.Errorf("plugin.go:%d 用裸 exec.Command 跑子进程 —— 必须 exec.CommandContext(ctx 取消才杀得掉): %s", i+1, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user