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:
JianFeeeee
2026-10-11 13:06:23 +08:00
committed by GitHub
parent a7f89a1b45
commit 16309c3307
4 changed files with 980 additions and 0 deletions

View File

@ -0,0 +1,171 @@
package seemedia
import (
"os"
"path/filepath"
"strings"
"testing"
)
// seemedia 让模型**一手**看图/看视频/听音频(SetToolBlocks 直投主模型),
// 与 multimodal(二手文字描述)并存。
//
// 这些判据钉住的是**不变量**,不是某次实现:
//
// ① 来源解析三条路(url 透传 / path 读盘转 data URL / digest 取 CAS)与优先级
// ② 没有任何来源时报错,且错误**可操作**(说清传什么)
// ③ 抽帧数永远被 clamp 到 [1, maxVideoFrames]——对齐内核 modalfallback 的约束
// ④ 直投工具必须声明 ParallelSafe:false(SetToolBlocks 写的是"当前 call"的块,
// 并发多工具会串台)
// ---- ① / ② 来源解析 ----
func TestResolveMediaURL_URLPassthrough(t *testing.T) {
p := &Plugin{}
got, err := p.resolveMediaURL(map[string]interface{}{"url": "https://x/y.png"}, "image")
if err != nil {
t.Fatalf("http 直链应原样透传: %v", err)
}
if got != "https://x/y.png" {
t.Errorf("url 应原样返回(不下载、不转 data URL),实际: %q", got)
}
// 非 http 的 url 要被拒(避免 file:// 之类绕过 path 分支的本地读)
if _, err := p.resolveMediaURL(map[string]interface{}{"url": "file:///etc/passwd"}, "image"); err == nil {
t.Error("非 http(s) 的 url 必须被拒绝")
}
}
func TestResolveMediaURL_PathWinsOverDigest(t *testing.T) {
p := &Plugin{}
dir := t.TempDir()
fp := filepath.Join(dir, "shot.jpg")
if err := os.WriteFile(fp, []byte{0xFF, 0xD8, 0xFF, 1, 2}, 0o644); err != nil {
t.Fatal(err)
}
// path 与 digest 同时给:path 优先,且**不碰 CAS**(p.sdk 为 nil 也不该 panic)
got, err := p.resolveMediaURL(map[string]interface{}{"path": fp, "digest": "deadbeef"}, "image")
if err != nil {
t.Fatalf("path 优先时应成功: %v", err)
}
if !strings.HasPrefix(got, "data:image/jpeg;base64,") {
t.Errorf("应按扩展名给 image/jpeg data URL,实际前缀: %.40s", got)
}
// url 又比 path 优先
got2, err := p.resolveMediaURL(map[string]interface{}{"url": "http://a/b", "path": fp}, "image")
if err != nil || got2 != "http://a/b" {
t.Errorf("url 应比 path 优先,实际: %q err=%v", got2, err)
}
}
func TestResolveMediaURL_NoSourceIsActionable(t *testing.T) {
p := &Plugin{}
_, err := p.resolveMediaURL(map[string]interface{}{}, "image")
if err == nil {
t.Fatal("无任何来源时必须报错")
}
msg := err.Error()
for _, want := range []string{"path", "digest", "url"} {
if !strings.Contains(msg, want) {
t.Errorf("错误信息要指明可传 %s,实际: %v", want, err)
}
}
}
func TestResolveMediaURL_FileErrorsSpecific(t *testing.T) {
p := &Plugin{}
if _, err := p.resolveMediaURL(map[string]interface{}{"path": "/nope/x.jpg"}, "image"); err == nil ||
!strings.Contains(err.Error(), "/nope/x.jpg") {
t.Errorf("读取失败应带路径,实际: %v", err)
}
dir := t.TempDir()
empty := filepath.Join(dir, "e.png")
if err := os.WriteFile(empty, nil, 0o644); err != nil {
t.Fatal(err)
}
if _, err := p.resolveMediaURL(map[string]interface{}{"path": empty}, "image"); err == nil ||
!strings.Contains(err.Error(), "空") {
t.Errorf("空文件应明说「空」,实际: %v", err)
}
}
func TestFallbackMIME(t *testing.T) {
if fallbackMIME("audio") != "audio/wav" {
t.Error("audio 兜底应为 audio/wav")
}
if fallbackMIME("image") != "image/png" {
t.Error("image 兜底应为 image/png")
}
// 未知 kind 落到 image(图是最常见的一手媒体)
if fallbackMIME("??") != "image/png" {
t.Error("未知 kind 应兜底 image/png")
}
}
// ---- ③ 抽帧数 clamp ----
func TestClampFrames(t *testing.T) {
cases := map[int]int{
-5: defaultVideoFrames, // 非正数回默认,而不是 0 帧(0 帧=白调一次)
0: defaultVideoFrames,
1: 1,
6: 6,
10: 10,
11: maxVideoFrames, // 超限夹到上限,不放任上游超体积
999: maxVideoFrames,
}
for in, want := range cases {
if got := clampFrames(in); got != want {
t.Errorf("clampFrames(%d)=%d,期望 %d", in, got, want)
}
}
// 不变量:上限必须 ≥ 默认,否则默认值会被自己夹掉
if maxVideoFrames < defaultVideoFrames {
t.Fatal("maxVideoFrames 不能小于 defaultVideoFrames")
}
}
func TestMimeByExt(t *testing.T) {
for p, want := range map[string]string{
"a.jpg": "image/jpeg", "a.JPEG": "image/jpeg", "a.png": "image/png",
"a.webp": "image/webp", "a.gif": "image/gif", "a.bmp": "image/bmp",
"a.wav": "audio/wav", "a.mp3": "audio/mpeg", "a.flac": "audio/flac",
"a.bin": "", "noext": "",
} {
if got := mimeByExt(p); got != want {
t.Errorf("mimeByExt(%q)=%q,期望 %q", p, got, want)
}
}
}
func TestIsHTTPURL(t *testing.T) {
for s, want := range map[string]bool{
"http://a": true,
"https://a": true,
"ftp://a": false,
"file:///x": false,
"/local/x": false,
"data:image": false,
} {
if got := isHTTPURL(s); got != want {
t.Errorf("isHTTPURL(%q)=%v,期望 %v", s, got, want)
}
}
}
// ---- ④ 直投工具的并发契约 ----
//
// SetToolBlocks 写的是"当前 call"的媒体块(IOManager.SetToolBlocks 无 call_id
// 时的兼容入口),并发执行多个直投工具会把彼此的块串到一起。所以三个工具都
// 必须 ParallelSafe:false。这条判据跨实现有效:谁把它改回 true,谁就引入串台。
func TestSeeToolsAreNotParallelSafe(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
// 不得出现 ParallelSafe:true(本插件所有工具都直投,必须串行)
if strings.Contains(strings.ReplaceAll(s, " ", ""), "ParallelSafe:true") {
t.Error("seemedia 的直投工具不得声明 ParallelSafe:true —— " +
"SetToolBlocks 写的是当前 call 的块,并发会串台")
}
}