From 16309c33070224197140df1c0d89f953a488c5cf Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:06:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(seemedia):=20=E5=A4=9A=E6=A8=A1=E6=80=81?= =?UTF-8?q?=E7=9B=B4=E8=A7=86=E6=8F=92=E4=BB=B6=E2=80=94=E2=80=94=E8=AE=A9?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=80=E6=89=8B=E7=9C=8B=E5=9B=BE/?= =?UTF-8?q?=E7=9C=8B=E8=A7=86=E9=A2=91/=E5=90=AC=E9=9F=B3=E9=A2=91=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- internal/plugins/all.go | 1 + internal/plugins/seemedia/integration_test.go | 241 ++++++++ internal/plugins/seemedia/plugin.go | 567 ++++++++++++++++++ internal/plugins/seemedia/plugin_test.go | 171 ++++++ 4 files changed, 980 insertions(+) create mode 100644 internal/plugins/seemedia/integration_test.go create mode 100644 internal/plugins/seemedia/plugin.go create mode 100644 internal/plugins/seemedia/plugin_test.go diff --git a/internal/plugins/all.go b/internal/plugins/all.go index f99bdfa1..bcdfae35 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -14,6 +14,7 @@ import ( _ "github.com/JianFeeeee/HomeAgent/internal/plugins/multimodal" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice" + _ "github.com/JianFeeeee/HomeAgent/internal/plugins/seemedia" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/seq" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/skillmgr" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/timer" diff --git a/internal/plugins/seemedia/integration_test.go b/internal/plugins/seemedia/integration_test.go new file mode 100644 index 00000000..3ac5b23d --- /dev/null +++ b/internal/plugins/seemedia/integration_test.go @@ -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)) + } + } +} diff --git a/internal/plugins/seemedia/plugin.go b/internal/plugins/seemedia/plugin.go new file mode 100644 index 00000000..41feb8c9 --- /dev/null +++ b/internal/plugins/seemedia/plugin.go @@ -0,0 +1,567 @@ +// Package seemedia 让模型**主动一手**去看图/看视频/听音频。 +// +// # 与 multimodal 插件的分工(2026-10-11 新增) +// +// `multimodal`(describe_image / transcribe_audio / ocr_image)走的是**二手**路: +// 插件自己起一次 VLM 子调用,把结果**文字**喂回主模型。省 token、能入记忆正文、 +// 主模型非视觉也能用,但模型看到的是「别人转述的描述」,细节丢失不可逆。 +// +// 本插件走的是**一手**路:把图/帧/音频的字节**直接投给主模型**,经 +// `PluginSDK.SetToolBlocks` → 内核在紧随的 user message 里带上 image_url/audio_url +// 块(task.go `ConsumeToolBlocks`)。模型是**自己看到**原始像素,而不是读描述。 +// +// 两者刻意并存(用户 2026-10-11 决策 coexist): +// - 要省 token、要把结论写进记忆、主模型不支持该模态 → describe_image(二手) +// - 要模型亲自判读细节(看清一张截图、逐帧看一段操作) → see_image/see_video(一手) +// +// # 直视不可用时会怎样(内核已兜底,插件不必关心) +// +// 主模型不支持视觉/音频时,内核 `prepareToolBlocks` 会把这些块走回退链批量 +// 转写成文字再并入上下文(modalfallback.go),**不会**把 image_url 静默塞给不支持 +// 的网关(那会被剥离后返回 200,模型答「没看到图」而内核以为成功)。所以本插件 +// 只管「把字节交给内核」,支不支持、要不要回退由内核决定。 +// +// # 媒体来源(用户决策 path + digest + url 三者都支持) +// +// path —— 设备/工具回传的落盘文件(local_screensee、browser_screenshot、mc 截图…) +// digest —— 用户上传已入 CAS 的媒体,用 sdk.Media().Get 取回,不在内核与插件间传 base64 +// url —— http(s) 直链;图/音频原样透传给模型(不下载,不入 CAS,交给上游拉取), +// 视频则交给 ffmpeg 直接读取(ffmpeg 原生支持 http 输入) +// +// # 媒体入记忆(天然打通,本插件无需自己写库) +// +// seemedia 经 SetToolBlocks 注入的块,内核在 task.go `captureBlockMedia` 处**无条件** +// 落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退转写,媒体 +// 本体都该进记忆」)。所以记忆载体块化之后,一手看到的图/帧**同时**进了记忆, +// 之后能按多模态向量被 doc_query / memory_recall 召回。本插件不写任何入库代码。 +// +// ★ 但有一个边界:`captureBlockMedia` 只对 **data URL** 做 CAS 入库,http(s) URL +// 会被跳过(不下载、不入记忆、不算向量——避免下载引入超时/SSRF)。因此: +// +// path / digest 来源 → 转 data URL → 进记忆块(可召回) +// url(http 直链) → 原样透传给模型看 → 模型看得到,但**不进记忆** +// +// 这是刻意取舍,不是缺陷:要让一份 http 媒体长期可召回,应先把它落盘再传 path。 +// +// # 为什么是内置插件而不是子进程插件(用户决策 builtin) +// +// 与 multimodal 并排,形态一致、最省心。代价是延续了「内核进程内读任意本地文件」 +// 这个 a02d8b00 本想消除的张力——但既然 multimodal 已是内置且同样读 path,这里 +// 保持一致比单独走子进程沙箱更不易让人困惑。若日后要收紧,两个插件应一起搬。 +package seemedia + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" +) + +const ( + // defaultVideoFrames 是 see_video 默认抽取的帧数。 + defaultVideoFrames = 6 + // maxVideoFrames 是硬上限。对齐内核 modalfallback 的「see_video 一次最多 10 帧」 + // 约束:帧越多上游越慢、越易超单请求体积限制,回退转写也只处理前若干块。 + maxVideoFrames = 10 +) + +func init() { + plugin.RegisterPluginMeta("seemedia", "多模态直视", "See Media") + plugin.RegisterFactory("seemedia", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} + +// Plugin 提供 see_image / see_video / hear_audio:把媒体字节直接投给主模型。 +type Plugin struct { + name string + sdk *sdk.PluginSDK + + videoFrames int + timeout time.Duration + ffmpeg string // ffmpeg 可执行路径(空=未探测到,see_video 会以 error 上抛) + ffprobe string +} + +// 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 + p.videoFrames = defaultVideoFrames + p.timeout = 120 * time.Second + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "video_frames", Default: strconv.Itoa(defaultVideoFrames), Type: "int", + DisplayName: "see_video 抽帧数", + Description: fmt.Sprintf("see_video 默认从视频里均匀抽取多少帧投给模型(1-%d)。"+ + "帧越多模型越能看清过程变化,但上游越慢、越易超体积限制。", maxVideoFrames), + Category: "seemedia", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "timeout_seconds", Default: "120", Type: "int", + DisplayName: "媒体处理超时(秒)", + Description: "单次取媒体/抽帧的时间上限。", + Category: "seemedia", + }) + p.loadSettings() + + // ffmpeg/ffprobe 只在 see_video 时需要;探测结果缓存,缺失时在工具描述里说清。 + p.ffmpeg, _ = exec.LookPath("ffmpeg") + p.ffprobe, _ = exec.LookPath("ffprobe") + + srcParam := map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "媒体文件路径(设备/工具回传的落盘文件,如 screensee / browser_screenshot 的截图)。" + + "path / digest / url 三者给其一。", + }, + "digest": map[string]interface{}{ + "type": "string", + "description": "媒体在内容寻址库(CAS)里的 digest(用户上传已入库的媒体)。", + }, + "url": map[string]interface{}{ + "type": "string", + "description": "媒体的 http(s) 直链。图片/音频原样交给模型,视频交给 ffmpeg 读取。", + }, + } + + if err := s.RegisterTool("see_image", sdk.ToolDef{ + Name: "see_image", + Description: "【亲自看图】把一张图片**原样投给你自己**去看——你会在下一步直接看到这张图的真实画面," + + "而不是别人转述的文字描述。需要判读截图、看清图里的细节/布局/颜色时用它。" + + "(只想要一段文字描述、或你本身不支持视觉时,用 describe_image 更省。)" + + "来源三选一:path / digest / url。", + Parameters: srcObject(srcParam), + ParallelSafe: false, + }, p.handleSeeImage); err != nil { + return err + } + + videoDesc := "【亲自看视频】从一段视频里均匀抽若干帧**原样投给你自己**去看,你会直接看到这些画面。" + + "适合判读一段操作录屏、监控片段、动图的过程变化。来源三选一:path / digest / url。" + + "可选 frames 指定抽帧数(1-" + strconv.Itoa(maxVideoFrames) + ",默认 " + strconv.Itoa(defaultVideoFrames) + ")。" + if p.ffmpeg == "" { + videoDesc += " ★本机未检测到 ffmpeg,调用会失败;请先安装 ffmpeg。" + } + if err := s.RegisterTool("see_video", sdk.ToolDef{ + Name: "see_video", + Description: videoDesc, + Parameters: p.videoParams(srcParam), + ParallelSafe: false, + }, p.handleSeeVideo); err != nil { + return err + } + + if err := s.RegisterTool("hear_audio", sdk.ToolDef{ + Name: "hear_audio", + Description: "【亲自听音频】把一段音频**原样投给你自己**去听,你会直接听到它," + + "而不是读一段转写文字。需要判断语气/音乐/非语音声音时用它。" + + "(只想要文字转写、或你本身不支持音频时,用 transcribe_audio 更省。)" + + "来源三选一:path / digest / url。", + Parameters: srcObject(srcParam), + ParallelSafe: false, + }, p.handleHearAudio); err != nil { + return err + } + return nil +} + +func (p *Plugin) Stop() error { return nil } + +func srcObject(props map[string]interface{}) map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": props} +} + +func (p *Plugin) videoParams(src map[string]interface{}) map[string]interface{} { + props := map[string]interface{}{} + for k, v := range src { + props[k] = v + } + props["frames"] = map[string]interface{}{ + "type": "integer", + "description": fmt.Sprintf("可选:抽取帧数(1-%d,默认 %d)。", maxVideoFrames, defaultVideoFrames), + } + return srcObject(props) +} + +func (p *Plugin) loadSettings() { + s := p.sdk.Settings() + if s == nil { + return + } + if v, _ := s.Get("video_frames"); v != nil { + if n := toInt(v); n > 0 { + p.videoFrames = clampFrames(n) + } + } + if v, _ := s.Get("timeout_seconds"); v != nil { + if n := toInt(v); n > 0 { + p.timeout = time.Duration(n) * time.Second + } + } +} + +// ── 工具处理 ─────────────────────────────────────────────── + +func (p *Plugin) handleSeeImage(args map[string]interface{}) (interface{}, error) { + url, err := p.resolveMediaURL(args, "image") + if err != nil { + return nil, err + } + p.sdk.SetToolBlocks([]sdk.ContentBlock{{ + Type: "image_url", + ImageURL: &sdk.ImageURL{URL: url, Detail: "auto"}, + }}) + return map[string]interface{}{ + "status": "ok", + "note": "图片已投给你——请在下一步直接查看画面本身(这不是文字描述)。", + }, nil +} + +func (p *Plugin) handleHearAudio(args map[string]interface{}) (interface{}, error) { + url, err := p.resolveMediaURL(args, "audio") + if err != nil { + return nil, err + } + p.sdk.SetToolBlocks([]sdk.ContentBlock{{ + Type: "audio_url", + AudioURL: &sdk.AudioURL{URL: url}, + }}) + return map[string]interface{}{ + "status": "ok", + "note": "音频已投给你——请在下一步直接聆听(这不是转写文字)。", + }, nil +} + +func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error) { + if p.ffmpeg == "" { + return nil, fmt.Errorf("本机未安装 ffmpeg,无法从视频抽帧;请先安装 ffmpeg(apt install ffmpeg 或等价命令)") + } + n := p.videoFrames + if v, ok := args["frames"]; ok { + if k := toInt(v); k > 0 { + n = clampFrames(k) + } + } + + input, cleanup, err := p.resolveVideoInput(args) + if err != nil { + return nil, err + } + defer cleanup() + + frames, err := p.extractFrames(input, n) + if err != nil { + return nil, err + } + if len(frames) == 0 { + return nil, fmt.Errorf("未能从视频抽到任何帧(文件可能损坏或不是视频)") + } + + blocks := make([]sdk.ContentBlock, 0, len(frames)) + for _, f := range frames { + dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(f) + blocks = append(blocks, sdk.ContentBlock{ + Type: "image_url", + ImageURL: &sdk.ImageURL{URL: dataURL, Detail: "auto"}, + }) + } + p.sdk.SetToolBlocks(blocks) + return map[string]interface{}{ + "status": "ok", + "frames": len(blocks), + "note": fmt.Sprintf("已抽取 %d 帧并投给你——请在下一步直接查看这些画面,"+ + "按时间顺序理解过程变化。", len(blocks)), + }, nil +} + +// ── 来源解析 ─────────────────────────────────────────────── + +// resolveMediaURL 把 path/digest/url 解析成可直接放进 image_url/audio_url 的字符串。 +// +// url → 原样返回(http 直链,交给上游拉取;不下载、不入 CAS) +// path → 读本地文件,转 data URL +// digest → 从 CAS 取回字节,转 data URL +// +// kind 仅用于在扩展名/元信息都缺失时猜一个兜底 MIME(image 或 audio)。 +func (p *Plugin) resolveMediaURL(args map[string]interface{}, kind string) (string, error) { + path := strings.TrimSpace(argStr(args, "path")) + digest := strings.TrimSpace(argStr(args, "digest")) + rawURL := strings.TrimSpace(argStr(args, "url")) + + switch { + case rawURL != "": + if !isHTTPURL(rawURL) { + return "", fmt.Errorf("url 必须是 http(s) 直链,实际: %q", rawURL) + } + return rawURL, nil + + case path != "": + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("读取文件失败 %s: %w", path, err) + } + if len(b) == 0 { + return "", fmt.Errorf("文件为空: %s", path) + } + return dataURL(mimeByExtOr(path, kind), b), nil + + case digest != "": + if p.sdk.Media() == nil { + return "", fmt.Errorf("内容寻址库不可用,无法按 digest 取媒体") + } + b, err := p.sdk.Media().Get(digest) + if err != nil { + return "", fmt.Errorf("按 digest 取媒体失败 %s: %w", digest, err) + } + mime := fallbackMIME(kind) + if info, serr := p.sdk.Media().Stat(digest); serr == nil && info.MIME != "" { + mime = info.MIME + } + return dataURL(mime, b), nil + } + return "", fmt.Errorf("没有可处理的媒体。请传 path(设备/工具回传的文件,如 screensee 的截图)、" + + "digest(CAS 里的媒体),或 url(http(s) 直链)三者之一") +} + +// resolveVideoInput 为 see_video 解析出一个 ffmpeg 能读的输入。 +// +// url → 原样交给 ffmpeg(ffmpeg 原生支持 http 输入),无需下载 +// path → 直接用该路径 +// digest → 从 CAS 取字节落到临时文件(ffmpeg 需要可寻址的输入) +// +// 返回的 cleanup 负责删掉临时文件(path/url 情形是 no-op)。 +func (p *Plugin) resolveVideoInput(args map[string]interface{}) (input string, cleanup func(), err error) { + noop := func() {} + path := strings.TrimSpace(argStr(args, "path")) + digest := strings.TrimSpace(argStr(args, "digest")) + rawURL := strings.TrimSpace(argStr(args, "url")) + + switch { + case rawURL != "": + if !isHTTPURL(rawURL) { + return "", noop, fmt.Errorf("url 必须是 http(s) 直链,实际: %q", rawURL) + } + return rawURL, noop, nil + + case path != "": + if st, e := os.Stat(path); e != nil { + return "", noop, fmt.Errorf("视频文件不可读 %s: %w", path, e) + } else if st.Size() == 0 { + return "", noop, fmt.Errorf("视频文件为空: %s", path) + } + return path, noop, nil + + case digest != "": + if p.sdk.Media() == nil { + return "", noop, fmt.Errorf("内容寻址库不可用,无法按 digest 取视频") + } + b, e := p.sdk.Media().Get(digest) + if e != nil { + return "", noop, fmt.Errorf("按 digest 取视频失败 %s: %w", digest, e) + } + f, e := os.CreateTemp("", "seemedia-*.video") + if e != nil { + return "", noop, fmt.Errorf("创建临时文件失败: %w", e) + } + if _, e := f.Write(b); e != nil { + f.Close() + os.Remove(f.Name()) + return "", noop, fmt.Errorf("写入临时视频失败: %w", e) + } + f.Close() + name := f.Name() + return name, func() { os.Remove(name) }, nil + } + return "", noop, fmt.Errorf("没有可处理的视频。请传 path、digest 或 url 之一") +} + +// ── 抽帧 ─────────────────────────────────────────────────── + +// extractFrames 从视频里均匀抽 n 帧,返回每帧的 PNG 字节。 +// +// 策略:先用 ffprobe 探时长,再在 duration*(i+0.5)/n 处逐帧快速定位(-ss 在 -i 前, +// 关键帧 seek,快)。探时长失败(直播流/损坏头)时退回单遍 fps 滤镜抽前 n 帧。 +func (p *Plugin) extractFrames(input string, n int) ([][]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), p.timeout) + defer cancel() + + if dur := p.probeDuration(ctx, input); dur > 0 { + var frames [][]byte + for i := 0; i < n; i++ { + ts := dur * (float64(i) + 0.5) / float64(n) + b, err := p.grabFrameAt(ctx, input, ts) + if err != nil || len(b) == 0 { + continue // 单帧失败不致命:有几帧投几帧 + } + frames = append(frames, b) + } + if len(frames) > 0 { + return frames, nil + } + // 定位抽帧全失败 → 落到单遍兜底 + } + return p.grabFramesFPS(ctx, input, n) +} + +func (p *Plugin) probeDuration(ctx context.Context, input string) float64 { + if p.ffprobe == "" { + return 0 + } + out, err := exec.CommandContext(ctx, p.ffprobe, + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + input, + ).Output() + if err != nil { + return 0 + } + d, _ := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if d < 0 { + return 0 + } + return d +} + +// grabFrameAt 在 ts 秒处抓 1 帧 PNG,输出到 stdout。 +func (p *Plugin) grabFrameAt(ctx context.Context, input string, ts float64) ([]byte, error) { + cmd := exec.CommandContext(ctx, p.ffmpeg, + "-nostdin", "-loglevel", "error", + "-ss", strconv.FormatFloat(ts, 'f', 3, 64), + "-i", input, + "-frames:v", "1", + "-f", "image2pipe", "-c:v", "png", + "pipe:1", + ) + var buf bytes.Buffer + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// grabFramesFPS 单遍抽前 n 帧(兜底,用于探不到时长的流)。 +func (p *Plugin) grabFramesFPS(ctx context.Context, input string, n int) ([][]byte, error) { + dir, err := os.MkdirTemp("", "seemedia-frames-") + if err != nil { + return nil, fmt.Errorf("创建临时目录失败: %w", err) + } + defer os.RemoveAll(dir) + + cmd := exec.CommandContext(ctx, p.ffmpeg, + "-nostdin", "-loglevel", "error", + "-i", input, + "-vf", "thumbnail", + "-frames:v", strconv.Itoa(n), + filepath.Join(dir, "f-%03d.png"), + ) + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg 抽帧失败: %w", err) + } + entries, _ := filepath.Glob(filepath.Join(dir, "f-*.png")) + var frames [][]byte + for _, e := range entries { + if b, err := os.ReadFile(e); err == nil && len(b) > 0 { + frames = append(frames, b) + } + } + return frames, nil +} + +// ── 纯助手 ───────────────────────────────────────────────── + +func clampFrames(n int) int { + if n < 1 { + return defaultVideoFrames + } + if n > maxVideoFrames { + return maxVideoFrames + } + return n +} + +func isHTTPURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +func dataURL(mime string, b []byte) string { + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b) +} + +func mimeByExtOr(path, kind string) string { + if m := mimeByExt(path); m != "" { + return m + } + return fallbackMIME(kind) +} + +func fallbackMIME(kind string) string { + if kind == "audio" { + return "audio/wav" + } + return "image/png" +} + +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 ".bmp": + return "image/bmp" + case ".wav": + return "audio/wav" + case ".mp3": + return "audio/mpeg" + case ".m4a", ".aac": + return "audio/aac" + case ".ogg", ".oga": + return "audio/ogg" + case ".flac": + return "audio/flac" + } + return "" +} + +func argStr(args map[string]interface{}, key string) string { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func toInt(v interface{}) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case string: + i, _ := strconv.Atoi(strings.TrimSpace(n)) + return i + } + return 0 +} diff --git a/internal/plugins/seemedia/plugin_test.go b/internal/plugins/seemedia/plugin_test.go new file mode 100644 index 00000000..6cfe157e --- /dev/null +++ b/internal/plugins/seemedia/plugin_test.go @@ -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 的块,并发会串台") + } +}