From 09071dc235567834e51ac77f64246b987cac7c7c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 06:25:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(multimodal):=20=E4=BF=AE=E5=A4=9A=E6=A8=A1?= =?UTF-8?q?=E6=80=81=E5=81=87=E6=88=90=E5=8A=9F=20+=20=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E5=9B=9E=E9=80=80=E9=93=BE=20+=20see=5Fvideo?= =?UTF-8?q?=20=E5=B8=A7=E6=95=B0=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 起因 生产盲测:模型调 multimodal_see_picture 后声称看到了图,实际一个字 都没收到。工具却返回「[已将图片注入后续对话]」。 链路:core.llm.model=AUTO → llmsproxy 按优先级选 big-pickle(prio=100) → 转 opencode zen。llmsproxy 的 opencode.lua 明写着: -- zen 上游 schema 只接受 text content part(无视觉/音频能力) if part.type ~= nil and part.type ~= "text" then -- 丢弃 判据:256x256 纯红 PNG,带图与不带图的 prompt_tokens 都是 256。 图片贡献零 token,即根本没进上游。 内核序列化与注入链本身是对的(Message.MarshalJSON 正确产出 content 数组,SetToolBlocks → IOManager → ConsumeToolBlocks → toolMsg.Blocks 全通)。缺的是「主模型能否消费这些块」这一判断——内核此前完全没有 多模态能力的概念(grep supportsVision|multimodal 在 agent/ 零命中)。 这与 v1.0.0 修的 output_send 假成功同类:告诉调用方成功而实际未送达。 ## 1. 能力声明 新增 core.llm.sources..vision / .audio(走既有 sourceFieldDefs, WebUI 配置页自动出现),types.LLMSource 与 api.BaseConfig 同步加字段。 新增 agentAPI.ModalProvider 接口 + ProviderSupportsVision/Audio 判定: 未实现该接口的 provider 一律按不支持处理。保守侧是刻意的——宁可多走 一次文字回退,也不能把图默默扔给会剥掉它的上游。 为何是声明而非探测:探测需额外真实调用且结果不稳定(取决于 AUTO 当次 路由到哪);而 200 响应 + 相同 token 数从响应侧无法区分「看到了但没 内容」和「被剥掉了」。 ## 2. 回退链(modalfallback.go) 实现了 config/registry.go 里注册但从未被读取的 image/audio fallback_provider + fallback_model(此前 0 处读取点)。 prepareToolBlocks 在 process.go 注入前判定:能直视就原样透传;不能就 调声明了该能力的源转写成文字,带 [由 X 转写,非当前模型直接感知] 标注。 几处刻意的设计: - 逐模态判定,不一刀切。很多视觉模型能看图但听不到音频,全部降级会 白白把可直视的图变成二手描述 - 混合场景下转写文字作为 text 块并入 native,两部分同时到达模型 - 配置指向未声明能力的源时拒绝并继续找——照用只会重演静默剥离 - 未配 fallback_provider 但某源声明了 vision 时自动扫出来用;静默失败 比多找一个能用的源更糟 - 空回复算失败。上游剥掉媒体后模型往往回「我没看到图片」或空串,两种 都说明回退链也没真看到 - 多媒体块按模态合包为一次请求(见下) ## 3. 批量合包(生产实测驱动的返工) 首版逐块调用,生产 see_video 6 帧实测:4 帧里 3 帧超时,整轮 363 秒。 改为按模态合包一次请求后同一用例 131 秒、6/6 成功。 顺带把 modalFallbackTimeout 从 90s 提到 180s:生产经网关转 claude-opus-5 看一张 400x400 图要 ~81s,90s 贴着上限。 多张时 detail 默认 low 控体积,单张用 high 看细节;插件显式给了 detail 则尊重它。 ## 4. see_video 帧数语义 fps=1/N 是频率(每 N 秒一帧)不是数量。20s 视频实测: frames=4 → 5 帧、frames=10 → 2 帧、frames=1 → 20 帧,要得越多拿得越少; 长视频下 frames=4 会产出 时长/4 帧,靠 i>=9 的 break 兜着才没炸上下文, 而那个 break 用的是 ReadDir 索引,跳过条目后与实际帧数错位。 改为 ffprobe 取时长 → fps=N/时长 + -frames:v N 硬封顶。 0.4s/3s/20s/120s × frames=1/2/4/7/10 全部精确。 极短视频的坑:fps=1 在 0.4s 素材上产出 0 帧(不足一秒抽不出),所以 时长探测失败时不能退化成 fps=1,改为不传 -vf 只靠 -frames:v。 ## 验证 - modalfallback_test.go 14 例:直视透传 / 回退转写 / 无源如实报告 / 未实现接口按不支持 / 混合模态拆分 / 空回复算失败 / 块数上限 / 多图合一次调用 / detail 策略 / 拒绝未声明能力的源 / 未配置时自动扫源 - go test ./... 全绿,go vet 无警告 - 生产盲测(答案预先封存、生成时不读):随机三色带 → 模型答 「紫、蓝、红」,与封存答案完全一致 - 负向验证:拿掉回退源后模型如实回答「没看到图片内容」并引用工具返回 的配置提示,且主动纠正了上一轮的答案 - 生产 see_video 6 帧:单次转写,模型正确描述测试图卡的计数器递增与 彩虹带滚动 --- cmd/homed/main.go | 2 + internal/agent/api/provider.go | 35 ++ internal/agent/core/modalfallback.go | 362 ++++++++++++++++++ internal/agent/core/modalfallback_test.go | 428 ++++++++++++++++++++++ internal/agent/core/process.go | 15 +- internal/config/registry.go | 4 + internal/plugins/multimodal/plugin.go | 98 +++-- internal/sdk/llm_impl.go | 2 + pkg/types/types.go | 11 + 9 files changed, 935 insertions(+), 22 deletions(-) create mode 100644 internal/agent/core/modalfallback.go create mode 100644 internal/agent/core/modalfallback_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index cca9973..6875d9f 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -295,6 +295,8 @@ func main() { ContextWindow: src.ContextWindow, MaxConcurrent: src.MaxConcurrent, Priority: src.Priority, + Vision: src.Vision, + Audio: src.Audio, }, luaVM, src.Name, src.Adapter) providerMgr.Register(src.Name, luaProvider) if src.Adapter != "" { diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 4953f25..0c5f429 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -235,6 +235,31 @@ type RoutableProvider interface { Priority() int // AUTO 跨源选择的优先级,大者优先 } +// ModalProvider 声明自身的多模态能力。单独抽接口而不合进 Provider: +// 第三方 Provider 实现无需改动,未实现时按纯文本处理(保守侧)。 +type ModalProvider interface { + SupportsVision() bool + SupportsAudio() bool +} + +// ProviderSupportsVision 安全判定任意 Provider 能否看图。 +// 未实现 ModalProvider 的一律返回 false:宁可多走一次文字回退, +// 也不能把图默默扔给一个会把它剥掉的上游。 +func ProviderSupportsVision(p Provider) bool { + if mp, ok := p.(ModalProvider); ok { + return mp.SupportsVision() + } + return false +} + +// ProviderSupportsAudio 安全判定任意 Provider 能否听音频。 +func ProviderSupportsAudio(p Provider) bool { + if mp, ok := p.(ModalProvider); ok { + return mp.SupportsAudio() + } + return false +} + // ModelContextWindow 返回模型的最大上下文窗口(token 数) // 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率 func ModelContextWindow(model string) int { @@ -284,6 +309,11 @@ type BaseConfig struct { ContextWindow int `json:"context_window"` MaxConcurrent int `json:"max_concurrent"` Priority int `json:"priority"` + + // Vision/Audio 声明这条链路能否真正处理多模态内容块。 + // 网关可能静默剥离 image_url 后仍返回 200,所以不能从响应推断能力。 + Vision bool `json:"vision"` + Audio bool `json:"audio"` } // LuaAdaptedProvider 使用 Lua 脚本做请求/响应变换,直接发起 HTTP 调用 @@ -343,6 +373,11 @@ func (p *LuaAdaptedProvider) Name() string { return p.name } func (p *LuaAdaptedProvider) Model() string { return p.cfg.Model } func (p *LuaAdaptedProvider) Priority() int { return p.cfg.Priority } +// SupportsVision/SupportsAudio 实现 ModalProvider,值来自部署时声明 +// (core.llm.sources..vision / .audio)。 +func (p *LuaAdaptedProvider) SupportsVision() bool { return p.cfg.Vision } +func (p *LuaAdaptedProvider) SupportsAudio() bool { return p.cfg.Audio } + func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { if p.cfg.Model != "" && (req.Model == "" || req.Model == "AUTO") { req.Model = p.cfg.Model diff --git a/internal/agent/core/modalfallback.go b/internal/agent/core/modalfallback.go new file mode 100644 index 0000000..37e745b --- /dev/null +++ b/internal/agent/core/modalfallback.go @@ -0,0 +1,362 @@ +package core + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// 多模态回退链:主模型看不到图/听不到音频时,改用一个声明了 vision/audio +// 能力的源把媒体转写成文字,再以 text block 注入。 +// +// 为何必须有这条链:core.llm.model=AUTO 时实际落到哪个上游由网关按优先级决定, +// 而网关可能把 image_url 块静默剥离后转发给纯文本上游(llmsproxy 的 +// opencode adapter 就明写着 "multimodal part not supported by zen" 并丢弃 +// 非 text part)。请求依然返回 200,带图与不带图的 prompt_tokens 完全相同, +// 模型于是回答「我没有看到图片」,而内核以为注入成功。 +// +// 没有这条链的话,multimodal 插件在任何非视觉主模型下都只能假成功。 + +const ( + // modalFallbackTimeout 单次转写调用的上限。 + // + // 为何是 180s:生产实测经网关转 claude-opus-5 看一张 400x400 图要 ~81s, + // 90s 阅则定时贴着上限,多帧批量请求更慢。宁可等也不要徒劳一趟。 + modalFallbackTimeout = 180 * time.Second + + // modalFallbackMaxTokens 转写输出上限。描述一组图/一段音频不需要长文, + // 且这段文字要塞回主模型上下文,过长会挤掉真正的对话内容。 + modalFallbackMaxTokens = 1500 + + // modalFallbackMaxBlocks 单次最多转写多少个媒体块。 + // see_video 一次能注入 10 帧;即使批量合包,图越多上游越慢也越容易超 + // 单请求体积限制。超出部分如实报告未转写。 + modalFallbackMaxBlocks = 6 +) + +// modalFallbackResult 描述一次回退转写的结果,供调用方决定注入什么。 +type modalFallbackResult struct { + // Text 是转写出的文字(已含来源标注),为空表示没有可注入内容。 + Text string + // Converted 实际成功转写的块数。 + Converted int + // Skipped 因超出 modalFallbackMaxBlocks 而未处理的块数。 + Skipped int + // Notice 给模型看的说明(能力缺失、转写失败等),始终如实。 + Notice string +} + +// resolveModalFallback 按配置挑一个能处理该模态的 provider。 +// +// 顺序:配置指定的 fallback_provider → 任意声明了该能力的已注册源。 +// 后者是刻意的兜底:用户可能只在源上声明了 vision 而忘了填 fallback_provider, +// 此时静默失败比多找一个能用的源更糟。 +func (a *Agent) resolveModalFallback(kind string) (agentAPI.Provider, string) { + if a.providerManager == nil { + return nil, "" + } + + var configured string + switch kind { + case "image": + configured = strings.TrimSpace(a.inputCfg.Image.FallbackProvider) + case "audio": + configured = strings.TrimSpace(a.inputCfg.Audio.FallbackProvider) + } + + supports := func(p agentAPI.Provider) bool { + if p == nil { + return false + } + if kind == "audio" { + return agentAPI.ProviderSupportsAudio(p) + } + return agentAPI.ProviderSupportsVision(p) + } + + if configured != "" { + p := a.providerManager.Get(configured) + if p == nil { + log.Printf("[agent] modal fallback %s: configured provider %q not registered", kind, configured) + } else if !supports(p) { + // 配置指向了一个没声明该能力的源:照用只会重演静默剥离, + // 因此拒绝并继续找,日志点明配置与声明不一致。 + log.Printf("[agent] modal fallback %s: provider %q does not declare the capability, ignoring", kind, configured) + } else if !a.providerManager.IsAvailable(configured) { + log.Printf("[agent] modal fallback %s: provider %q in cooldown, trying others", kind, configured) + } else { + return p, configured + } + } + + // 兜底:扫已注册源,取第一个声明了该能力且当前可用的。 + for _, name := range a.providerManager.List() { + if name == configured { + continue // 上面已试过 + } + p := a.providerManager.Get(name) + if supports(p) && a.providerManager.IsAvailable(name) { + return p, name + } + } + return nil, "" +} + +// modalFallbackModel 返回该模态回退调用应使用的模型名(空则用源自身默认)。 +func (a *Agent) modalFallbackModel(kind string) string { + switch kind { + case "image": + return strings.TrimSpace(a.inputCfg.Image.FallbackModel) + case "audio": + return strings.TrimSpace(a.inputCfg.Audio.FallbackModel) + } + return "" +} + +// modalFallbackPrompt 返回转写用的提示词,配置为空时给一个可用默认。 +func (a *Agent) modalFallbackPrompt(kind string) string { + switch kind { + case "image": + if s := strings.TrimSpace(a.inputCfg.Image.DescribePrompt); s != "" { + return s + } + return "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。" + case "audio": + if s := strings.TrimSpace(a.inputCfg.Audio.DescribePrompt); s != "" { + return s + } + return "请转写这段音频的内容。" + } + return "" +} + +// transcribeBlocksForFallback 把主模型看不懂的媒体块转写成文字。 +// +// blocks 里的 text 块原样保留(它们本来就能被理解);image_url/audio_url +// **按模态批量合包,每类只发一次请求**。 +// +// 为何必须批量而不是逐块:生产实测 see_video 注入 6 帧时,逐帧调用让 +// 4 帧里 3 帧超时,整轮拖到 363 秒。而视觉模型本来就能在一条消息里看 +// 多张图——一次调用不仅快上一个数量级,模型还能看到帧与帧的时间推进 +// 关系,分析质量更好。 +// +// 返回的 Text 已带来源标注,让模型知道这是转写而非自己直接看到的。这点 +// 很重要:模型据此能判断细节可靠性,也不会在用户追问像素级细节时编造。 +func (a *Agent) transcribeBlocksForFallback(blocks []agentAPI.ContentBlock) modalFallbackResult { + var res modalFallbackResult + var kept []string // 原样保留的 text 块 + var converted []string // 转写结果 + var notices []string + + // 先按模态分组,同时应用块数上限。 + var imgURLs, imgDetails []string + var audURLs []string + mediaSeen := 0 + for _, b := range blocks { + switch b.Type { + case "text": + if b.Text != "" { + kept = append(kept, b.Text) + } + continue + case "image_url": + mediaSeen++ + if mediaSeen > modalFallbackMaxBlocks { + res.Skipped++ + continue + } + if b.ImageURL != nil && b.ImageURL.URL != "" { + imgURLs = append(imgURLs, b.ImageURL.URL) + imgDetails = append(imgDetails, b.ImageURL.Detail) + } + case "audio_url": + mediaSeen++ + if mediaSeen > modalFallbackMaxBlocks { + res.Skipped++ + continue + } + if b.AudioURL != nil && b.AudioURL.URL != "" { + audURLs = append(audURLs, b.AudioURL.URL) + } + default: + continue // 未知块类型:主模型也看不懂,丢弃 + } + } + + // 图片:一次请求带全部帧 + if len(imgURLs) > 0 { + p, srcName := a.resolveModalFallback("image") + if p == nil { + notices = append(notices, "当前模型不支持图片,且没有可用的视觉回退源"+ + "(配置 core.input_processing.image.fallback_provider,"+ + "并在该源上设置 core.llm.sources..vision=true)") + } else if text, err := a.chatModalFallbackBatch(p, "image", imgURLs, imgDetails); err != nil { + // 转写失败必须说出来。静默跳过会让模型以为「图里没内容」, + // 而事实是没人看过这些图。 + notices = append(notices, fmt.Sprintf("图片转写失败(源 %s): %v", srcName, err)) + log.Printf("[agent] modal fallback image(%d) via %s failed: %v", len(imgURLs), srcName, err) + } else { + label := "图片内容" + if len(imgURLs) > 1 { + label = fmt.Sprintf("%d 张图片/视频帧内容", len(imgURLs)) + } + converted = append(converted, fmt.Sprintf("[%s · 由 %s 转写,非当前模型直接感知]\n%s", label, srcName, text)) + res.Converted += len(imgURLs) + } + } + + // 音频:同样一次请求 + if len(audURLs) > 0 { + p, srcName := a.resolveModalFallback("audio") + if p == nil { + notices = append(notices, "当前模型不支持音频,且没有可用的音频回退源"+ + "(配置 core.input_processing.audio.fallback_provider,"+ + "并在该源上设置 core.llm.sources..audio=true)") + } else if text, err := a.chatModalFallbackBatch(p, "audio", audURLs, nil); err != nil { + notices = append(notices, fmt.Sprintf("音频转写失败(源 %s): %v", srcName, err)) + log.Printf("[agent] modal fallback audio(%d) via %s failed: %v", len(audURLs), srcName, err) + } else { + converted = append(converted, fmt.Sprintf("[音频内容 · 由 %s 转写,非当前模型直接感知]\n%s", srcName, text)) + res.Converted += len(audURLs) + } + } + + if res.Skipped > 0 { + notices = append(notices, fmt.Sprintf( + "另有 %d 个媒体块未转写(单次上限 %d)", + res.Skipped, modalFallbackMaxBlocks)) + } + + var parts []string + parts = append(parts, kept...) + parts = append(parts, converted...) + if len(notices) > 0 { + parts = append(parts, "[注意] "+strings.Join(notices, ";")) + } + res.Text = strings.Join(parts, "\n\n") + res.Notice = strings.Join(notices, ";") + return res +} + +// prepareToolBlocks 判定当前主模型能否直接消费这批媒体块。 +// +// 返回 (native, ""):能直接看/听,原样作为 Blocks 注入。 +// 返回 (nil, text) :不能,已经回退链转写成文字,调用方并进纯文本 content。 +// 返回 (nil, "") :既不能直接看也没回退源且无话可说(理论上不发生, +// transcribeBlocksForFallback 至少会给一条 notice)。 +// +// 为何逐模态判定而不是一刀切:一批块里可能图能看、音频不能听(很多视觉 +// 模型就是这样)。全部走回退会白白把本可直视的图降级成二手文字描述。 +func (a *Agent) prepareToolBlocks(blocks []agentAPI.ContentBlock) ([]agentAPI.ContentBlock, string) { + canVision := agentAPI.ProviderSupportsVision(a.provider) + canAudio := agentAPI.ProviderSupportsAudio(a.provider) + + var native []agentAPI.ContentBlock + var needFallback []agentAPI.ContentBlock + for _, b := range blocks { + switch b.Type { + case "image_url": + if canVision { + native = append(native, b) + } else { + needFallback = append(needFallback, b) + } + case "audio_url": + if canAudio { + native = append(native, b) + } else { + needFallback = append(needFallback, b) + } + default: + native = append(native, b) // text 等一律直通 + } + } + + if len(needFallback) == 0 { + return native, "" + } + + res := a.transcribeBlocksForFallback(needFallback) + log.Printf("[agent] modal fallback: %d block(s) transcribed, %d skipped (provider=%s vision=%v audio=%v)", + res.Converted, res.Skipped, a.provider.Name(), canVision, canAudio) + + // 部分能直视、部分需转写:把转写文字作为 text 块并入 native, + // 这样两部分内容同时到达模型。 + if len(native) > 0 { + if res.Text != "" { + native = append(native, agentAPI.ContentBlock{Type: "text", Text: res.Text}) + } + return native, "" + } + return nil, res.Text +} + +// chatModalFallbackBatch 向回退 provider 发**一次**请求,带上该模态的全部媒体。 +// +// 多张图合包而非逐张调用:既为避开 N 倍往返延迟(生产实测逐帧调用使 +// see_video 6 帧拖到 363 秒且 3/4 帧超时),也因为视觉模型看到成组帧时能 +// 描述帧间变化,而逐帧转写只能得到 N 段互不相关的静态描述。 +func (a *Agent) chatModalFallbackBatch(p agentAPI.Provider, kind string, urls, details []string) (string, error) { + if len(urls) == 0 { + return "", fmt.Errorf("no media to transcribe") + } + + prompt := a.modalFallbackPrompt(kind) + if len(urls) > 1 && kind == "image" { + // 多张时补一句,否则模型容易只描述第一张。 + prompt = fmt.Sprintf("%s\n\n共 %d 张(若为视频关键帧则按时间顺序),"+ + "请逐张编号描述,并在最后概括帧间变化。", prompt, len(urls)) + } + + msg := agentAPI.Message{ + Role: "user", + Blocks: []agentAPI.ContentBlock{{Type: "text", Text: prompt}}, + } + for i, u := range urls { + if kind == "audio" { + msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{ + Type: "audio_url", + AudioURL: &agentAPI.AudioURL{URL: u}, + }) + continue + } + detail := "" + if i < len(details) { + detail = details[i] + } + if detail == "" { + // 单张时看清细节;多张(视频帧)用 low 控住体积与耗时。 + if len(urls) > 1 { + detail = "low" + } else { + detail = "high" + } + } + msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{ + Type: "image_url", + ImageURL: &agentAPI.ImageURL{URL: u, Detail: detail}, + }) + } + + ctx, cancel := context.WithTimeout(a.ctx, modalFallbackTimeout) + defer cancel() + resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{ + Model: a.modalFallbackModel(kind), + Messages: []agentAPI.Message{msg}, + MaxTokens: modalFallbackMaxTokens, + }) + if err != nil { + return "", err + } + out := strings.TrimSpace(resp.Content) + if out == "" { + // 空回复不能当成功。上游剥掉媒体块后模型往往回一句「我没看到图片」 + // 或干脆空串——两种都说明这条回退链也没真看到。 + return "", fmt.Errorf("回退源返回空内容(该源可能同样不支持此模态)") + } + return out, nil +} diff --git a/internal/agent/core/modalfallback_test.go b/internal/agent/core/modalfallback_test.go new file mode 100644 index 0000000..d2d925c --- /dev/null +++ b/internal/agent/core/modalfallback_test.go @@ -0,0 +1,428 @@ +package core + +import ( + "context" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +// stubProvider 是一个可声明多模态能力的假 provider。 +// 记录收到的请求,供断言「回退链是否真的调了它」。 +type stubProvider struct { + name string + vision bool + audio bool + reply string + err error + calls int + lastReq *agentAPI.CompletionRequest +} + +func (s *stubProvider) Name() string { return s.name } +func (s *stubProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + s.calls++ + s.lastReq = req + if s.err != nil { + return nil, s.err + } + return &agentAPI.CompletionResponse{Content: s.reply}, nil +} +func (s *stubProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + ch := make(chan agentAPI.StreamChunk) + close(ch) + return ch, nil +} +func (s *stubProvider) MaxContextTokens() int { return 8192 } +func (s *stubProvider) SupportsVision() bool { return s.vision } +func (s *stubProvider) SupportsAudio() bool { return s.audio } + +// plainProvider 不实现 ModalProvider,用于验证「未声明即按不支持处理」。 +type plainProvider struct{ name string } + +func (p *plainProvider) Name() string { return p.name } +func (p *plainProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + return &agentAPI.CompletionResponse{Content: "ok"}, nil +} +func (p *plainProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + ch := make(chan agentAPI.StreamChunk) + close(ch) + return ch, nil +} +func (p *plainProvider) MaxContextTokens() int { return 8192 } + +const testPNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==" +const testWAV = "data:audio/wav;base64,UklGRiQAAABXQVZF" + +func imageBlock() agentAPI.ContentBlock { + return agentAPI.ContentBlock{ + Type: "image_url", + ImageURL: &agentAPI.ImageURL{URL: testPNG, Detail: "auto"}, + } +} + +func audioBlock() agentAPI.ContentBlock { + return agentAPI.ContentBlock{ + Type: "audio_url", + AudioURL: &agentAPI.AudioURL{URL: testWAV}, + } +} + +// newFallbackAgent 组装一个只带 provider/manager/inputCfg 的最小 Agent。 +// 不走 New():那会拉起 embedder、记忆、后台循环,与本测试无关。 +func newFallbackAgent(main agentAPI.Provider, mgr *agentAPI.ProviderManager, cfg types.InputProcessingConfig) *Agent { + ctx, cancel := context.WithCancel(context.Background()) + return &Agent{ + provider: main, + providerManager: mgr, + inputCfg: cfg, + ctx: ctx, + cancel: cancel, + } +} + +func TestPrepareToolBlocks_VisionCapableProviderPassesThrough(t *testing.T) { + main := &stubProvider{name: "vision-main", vision: true} + mgr := agentAPI.NewProviderManager() + mgr.Register("vision-main", main) + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{}) + + native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()}) + + if len(native) != 1 || native[0].Type != "image_url" { + t.Fatalf("能看图的主模型应原样透传 image_url,得到 %+v", native) + } + if fallbackText != "" { + t.Fatalf("不该触发回退,却返回了文字: %q", fallbackText) + } + if main.calls != 0 { + t.Fatalf("不该额外调用 provider,实际调了 %d 次", main.calls) + } +} + +func TestPrepareToolBlocks_TextOnlyProviderFallsBackToTranscription(t *testing.T) { + main := &stubProvider{name: "text-main"} // vision=false + vis := &stubProvider{name: "vis-src", vision: true, reply: "一只橘猫坐在窗台上"} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("vis-src", vis) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Image: types.ImageProcessingConfig{ + FallbackProvider: "vis-src", + DescribePrompt: "描述这张图", + }, + }) + + native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()}) + + if len(native) != 0 { + t.Fatalf("纯文本主模型不该收到原生块,得到 %+v", native) + } + if !strings.Contains(fallbackText, "一只橘猫坐在窗台上") { + t.Fatalf("回退文字应含转写内容,得到 %q", fallbackText) + } + // 关键:模型必须知道这是二手转写而非自己直接看到的 + if !strings.Contains(fallbackText, "非当前模型直接感知") { + t.Fatalf("回退文字必须标注来源,得到 %q", fallbackText) + } + if vis.calls != 1 { + t.Fatalf("应调用视觉源 1 次,实际 %d", vis.calls) + } + if main.calls != 0 { + t.Fatalf("不该拿图去问纯文本主模型,实际调了 %d 次", main.calls) + } +} + +func TestPrepareToolBlocks_NoFallbackSourceReportsHonestly(t *testing.T) { + main := &stubProvider{name: "text-main"} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{}) + + native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()}) + + if len(native) != 0 { + t.Fatalf("不该透传,得到 %+v", native) + } + // 这是本次修复的核心:没有能力也没有回退源时必须明说, + // 而不是静默丢弃让模型以为自己看过图了。 + if !strings.Contains(fallbackText, "不支持图片") { + t.Fatalf("必须如实说明看不到图,得到 %q", fallbackText) + } + if !strings.Contains(fallbackText, "fallback_provider") { + t.Fatalf("应给出可操作的配置提示,得到 %q", fallbackText) + } +} + +func TestPrepareToolBlocks_ProviderWithoutModalInterfaceTreatedAsTextOnly(t *testing.T) { + main := &plainProvider{name: "legacy"} // 未实现 ModalProvider + mgr := agentAPI.NewProviderManager() + mgr.Register("legacy", main) + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{}) + + native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()}) + + if len(native) != 0 { + t.Fatalf("未声明能力的 provider 应按不支持处理,却透传了 %+v", native) + } + if fallbackText == "" { + t.Fatal("应给出说明而非静默") + } +} + +func TestPrepareToolBlocks_MixedModalitySplitsCorrectly(t *testing.T) { + // 主模型能看图但听不到音频——很多视觉模型正是这样。 + // 图应直视,只有音频走回退,不能一刀切全部降级。 + main := &stubProvider{name: "vision-only", vision: true} + aud := &stubProvider{name: "aud-src", audio: true, reply: "背景有钢琴声"} + mgr := agentAPI.NewProviderManager() + mgr.Register("vision-only", main) + mgr.Register("aud-src", aud) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Audio: types.AudioProcessingConfig{FallbackProvider: "aud-src"}, + }) + + native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock(), audioBlock()}) + + if fallbackText != "" { + t.Fatalf("有原生块时转写应并入 native,不该走 content 分支,得到 %q", fallbackText) + } + var imgCount, textCount int + for _, b := range native { + switch b.Type { + case "image_url": + imgCount++ + case "text": + textCount++ + if !strings.Contains(b.Text, "背景有钢琴声") { + t.Fatalf("text 块应含音频转写,得到 %q", b.Text) + } + } + } + if imgCount != 1 { + t.Fatalf("图应原样保留 1 个,得到 %d", imgCount) + } + if textCount != 1 { + t.Fatalf("音频转写应产出 1 个 text 块,得到 %d", textCount) + } + if aud.calls != 1 { + t.Fatalf("应调音频源 1 次,实际 %d", aud.calls) + } +} + +func TestTranscribeBlocks_EmptyReplyCountsAsFailure(t *testing.T) { + // 回退源返回空串,往往意味着它上游也剥掉了媒体块。 + // 这种情况绝不能当成功——否则又是一次假成功。 + main := &stubProvider{name: "text-main"} + vis := &stubProvider{name: "vis-src", vision: true, reply: " "} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("vis-src", vis) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"}, + }) + + res := a.transcribeBlocksForFallback([]agentAPI.ContentBlock{imageBlock()}) + + if res.Converted != 0 { + t.Fatalf("空回复不应计入成功转写,得到 Converted=%d", res.Converted) + } + if !strings.Contains(res.Notice, "转写失败") { + t.Fatalf("应报告转写失败,得到 Notice=%q", res.Notice) + } +} + +func TestTranscribeBlocks_CapsBlockCount(t *testing.T) { + // see_video 能一次注入 10 帧。上限存在的理由不再是“逐帧调用慢”(现已合包), + // 而是图越多单请求体积越大、上游越慢且易超限。 + main := &stubProvider{name: "text-main"} + vis := &stubProvider{name: "vis-src", vision: true, reply: "逐帧描述…"} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("vis-src", vis) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"}, + }) + + blocks := make([]agentAPI.ContentBlock, 10) + for i := range blocks { + blocks[i] = imageBlock() + } + res := a.transcribeBlocksForFallback(blocks) + + if res.Converted != modalFallbackMaxBlocks { + t.Fatalf("应只转写 %d 个,实际 %d", modalFallbackMaxBlocks, res.Converted) + } + if res.Skipped != 10-modalFallbackMaxBlocks { + t.Fatalf("应跳过 %d 个,实际 %d", 10-modalFallbackMaxBlocks, res.Skipped) + } + // 批量合包:上限内的帧应合成**一次**调用,而不是每帧一次。 + // 生产实测逐帧调用使 see_video 6 帧拖到 363s 且 3 帧超时。 + if vis.calls != 1 { + t.Fatalf("多帧应合包为 1 次调用,实际 %d 次", vis.calls) + } + // 且那一次请求里应带满上限数量的 image 块(加一个 text 提示块) + if vis.lastReq == nil || len(vis.lastReq.Messages) != 1 { + t.Fatal("应只发一条 user 消息") + } + imgBlocks := 0 + for _, b := range vis.lastReq.Messages[0].Blocks { + if b.Type == "image_url" { + imgBlocks++ + } + } + if imgBlocks != modalFallbackMaxBlocks { + t.Fatalf("单次请求应带 %d 个 image 块,实际 %d", modalFallbackMaxBlocks, imgBlocks) + } + // 跳过的部分也必须告知,否则模型以为自己看全了整段视频 + if !strings.Contains(res.Notice, "未转写") { + t.Fatalf("应告知有块未转写,得到 %q", res.Notice) + } +} + +func TestTranscribeBlocks_MultiImageBatchedIntoOneCall(t *testing.T) { + // 上限以内的多张图(典型:see_video 4 帧)同样只能一次调用。 + main := &stubProvider{name: "text-main"} + vis := &stubProvider{name: "vis-src", vision: true, reply: "1) 开场 2) 中段 3) 结尾"} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("vis-src", vis) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"}, + }) + + res := a.transcribeBlocksForFallback([]agentAPI.ContentBlock{ + imageBlock(), imageBlock(), imageBlock(), + }) + + if vis.calls != 1 { + t.Fatalf("3 张图应合为 1 次调用,实际 %d", vis.calls) + } + if res.Converted != 3 { + t.Fatalf("应计入 3 个已转写,实际 %d", res.Converted) + } + if res.Skipped != 0 { + t.Fatalf("不该有跳过,实际 %d", res.Skipped) + } + // 多张时提示词应补上张数,否则模型容易只描述第一张 + prompt := vis.lastReq.Messages[0].Blocks[0].Text + if !strings.Contains(prompt, "3 张") { + t.Fatalf("多张提示词应声明张数,得到 %q", prompt) + } + // 帧序列:插件显式给了 detail 就尊重它(see_video 本来就传 low), + // 只在未指定时才由回退链按张数选默认。 + for _, b := range vis.lastReq.Messages[0].Blocks { + if b.Type == "image_url" && b.ImageURL.Detail != "auto" { + t.Fatalf("应保留插件显式指定的 detail=auto,得到 %q", b.ImageURL.Detail) + } + } +} + +func TestTranscribeBlocks_MultiImageDefaultsToLowDetail(t *testing.T) { + // 未指定 detail 的多张图(帧序列)用 low 控住体积与耗时。 + main := &stubProvider{name: "text-main"} + vis := &stubProvider{name: "vis-src", vision: true, reply: "三帧描述"} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("vis-src", vis) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"}, + }) + + bare := agentAPI.ContentBlock{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: testPNG}} + a.transcribeBlocksForFallback([]agentAPI.ContentBlock{bare, bare, bare}) + + for _, b := range vis.lastReq.Messages[0].Blocks { + if b.Type == "image_url" && b.ImageURL.Detail != "low" { + t.Fatalf("多张未指定时应默认 low,得到 %q", b.ImageURL.Detail) + } + } +} + +func TestTranscribeBlocks_SingleImageUsesHighDetail(t *testing.T) { + // 单张图(see_picture)要看清细节,不吝惜 token。 + main := &stubProvider{name: "text-main"} + vis := &stubProvider{name: "vis-src", vision: true, reply: "一只橘猫"} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("vis-src", vis) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"}, + }) + + // Detail 置空,让回退链自己定 + a.transcribeBlocksForFallback([]agentAPI.ContentBlock{ + {Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: testPNG}}, + }) + + for _, b := range vis.lastReq.Messages[0].Blocks { + if b.Type == "image_url" && b.ImageURL.Detail != "high" { + t.Fatalf("单张应用 high detail,得到 %q", b.ImageURL.Detail) + } + } +} + +func TestResolveModalFallback_RejectsProviderNotDeclaringCapability(t *testing.T) { + // 配置指向一个没声明 vision 的源:照用只会重演静默剥离。 + // 应拒绝它,并继续找真正声明了能力的源。 + main := &stubProvider{name: "text-main"} + wrong := &stubProvider{name: "wrong-src"} // vision=false + right := &stubProvider{name: "right-src", vision: true} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("wrong-src", wrong) + mgr.Register("right-src", right) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{ + Image: types.ImageProcessingConfig{FallbackProvider: "wrong-src"}, + }) + + p, name := a.resolveModalFallback("image") + if name != "right-src" { + t.Fatalf("应跳过未声明能力的 wrong-src 而选中 right-src,得到 %q", name) + } + if p == nil { + t.Fatal("应返回可用 provider") + } +} + +func TestResolveModalFallback_ScansForCapableSourceWhenUnconfigured(t *testing.T) { + // 用户可能只在源上声明了 vision 却忘了填 fallback_provider。 + // 静默失败比多找一个能用的源更糟。 + main := &stubProvider{name: "text-main"} + vis := &stubProvider{name: "some-vision-src", vision: true} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + mgr.Register("some-vision-src", vis) + + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{}) + + _, name := a.resolveModalFallback("image") + if name != "some-vision-src" { + t.Fatalf("未配置时应扫出声明了能力的源,得到 %q", name) + } +} + +func TestPrepareToolBlocks_TextBlocksAlwaysPassThrough(t *testing.T) { + main := &stubProvider{name: "text-main"} + mgr := agentAPI.NewProviderManager() + mgr.Register("text-main", main) + a := newFallbackAgent(main, mgr, types.InputProcessingConfig{}) + + native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{ + {Type: "text", Text: "纯文字说明"}, + }) + + if len(native) != 1 || native[0].Text != "纯文字说明" { + t.Fatalf("text 块应无条件直通,得到 %+v / %q", native, fallbackText) + } +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 5d451d8..7d8041a 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -316,6 +316,9 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri // 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block, // process.go 拾起并追加到 tool message 的 content 数组(OpenAI 多模态格式), // 让下一轮 LLM 请求在 tool message 里看到图/音频。 + // + // 主模型不支持该模态时不能直接塞:网关会把 image_url 静默剥离后仍返回 200, + // 模型回答「我没有看到图片」而内核以为注入成功。改走回退链转写成文字。 toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result} if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 { var blocks []agentAPI.ContentBlock @@ -333,7 +336,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } } if len(blocks) > 0 { - toolMsg.Blocks = blocks + if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 { + toolMsg.Blocks = native + } else if fallbackText != "" { + // 回退链已把媒体转写成文字:并进 tool message 的纯文本 content, + // 不再挂 Blocks(挂了也会被上游剥掉)。 + toolMsg.Content = result + "\n\n" + fallbackText + result = toolMsg.Content + if len(toolResults) > 0 { + toolResults[len(toolResults)-1].Output = result + } + } } } msgs = append(msgs, toolMsg) diff --git a/internal/config/registry.go b/internal/config/registry.go index 1fdbc22..75c7c50 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -188,6 +188,8 @@ var sourceFieldDefs = []struct { {"adapter_path", "string", "适配器路径"}, {"max_concurrent", "int", "并发上限"}, {"priority", "int", "AUTO 优先级(大者优先)"}, + {"vision", "bool", "支持图片"}, + {"audio", "bool", "支持音频"}, } // registerSourceDefs 注册 core.llm.sources..* 的 ConfigDef @@ -861,6 +863,8 @@ func (r *ConfigRegistry) ToConfig() *types.Config { MaxConcurrent: readInt(p+".max_concurrent", 8), Priority: readInt(p+".priority", 0), ThinkingEnabled: readBool(p+".thinking_enabled", false), + Vision: readBool(p+".vision", false), + Audio: readBool(p+".audio", false), }) } diff --git a/internal/plugins/multimodal/plugin.go b/internal/plugins/multimodal/plugin.go index 4ff2c92..92d91c9 100644 --- a/internal/plugins/multimodal/plugin.go +++ b/internal/plugins/multimodal/plugin.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -61,7 +62,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, "frames": map[string]interface{}{ "type": "integer", - "description": "提取关键帧数量(默认 4,最大 10)", + "description": "均匀抽取的关键帧数量(默认 4,最大 10)。按视频总时长均分,不是每几秒一帧。", }, }, "required": []string{"path"}, @@ -175,6 +176,21 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error } } + // 用 ffprobe 拿时长,才能把「抽 N 帧」翻译成 ffmpeg 的帧率。 + // + // 为何不能直接写 fps=1/N:fps 是**频率**(每 N 秒一帧),不是**数量**。 + // 20 秒视频实测:fps=1/4 → 5 帧,fps=1/10 → 2 帧,fps=1/1 → 20 帧—— + // 要得越多拿得越少,且长视频下 frames=4 会产出时长/4 帧直接炸上下文。 + // 正确写法是 fps=N/时长 配 -frames:v N(实测 N=1/4/10 均精确)。 + dur := probeDuration(ffmpegPath, path) + var vfArgs []string + if dur > 0 { + vfArgs = []string{"-vf", fmt.Sprintf("fps=%d/%.3f", nFrames, dur)} + } + // 拿不到时长(无 ffprobe / 容器无时长元数据):不传 -vf,只靠 -frames:v + // 取开头 N 帧。不能退化成 fps=1:不足 1 秒的素材一帧也抽不出来(实测 + // 0.4s 视频 fps=1 → 0 帧),而 fps=N/dur 在 0.4s 上依然精确。 + // 用 ffmpeg 提取关键帧 tmpDir, err := os.MkdirTemp("", "mm_video_*") if err != nil { @@ -183,36 +199,46 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error defer os.RemoveAll(tmpDir) outPattern := filepath.Join(tmpDir, "frame_%03d.jpg") - cmd := exec.Command(ffmpegPath, "-i", path, "-vf", fmt.Sprintf("fps=1/%d", nFrames), - "-q:v", "5", outPattern) + // -frames:v 硬封顶:即使 fps 计算因时长误差多给了帧,也不会超出请求数量。 + ffArgs := []string{"-v", "error", "-i", path} + ffArgs = append(ffArgs, vfArgs...) + ffArgs = append(ffArgs, "-q:v", "5", "-frames:v", strconv.Itoa(nFrames), outPattern) + cmd := exec.Command(ffmpegPath, ffArgs...) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Sprintf("ffmpeg 提取帧失败: %v\n%s", err, string(out)), nil } - // 读取提取的帧 + // 读取提取的帧。按 blocks 长度而非目录索引封顶: + // 跳过的条目(非 jpg / 读失败 / 过大)会让索引与实际帧数错位。 entries, _ := os.ReadDir(tmpDir) var blocks []pubsdk.ContentBlock - for i, entry := range entries { - if strings.HasSuffix(entry.Name(), ".jpg") { - b, err := os.ReadFile(filepath.Join(tmpDir, entry.Name())) - if err != nil { - continue - } - if len(b) > 2*1024*1024 { - continue // 跳过过大帧 - } - dURL := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(b) - blocks = append(blocks, pubsdk.ContentBlock{ - Type: "image_url", - ImageURL: &pubsdk.ImageURL{URL: dURL, Detail: "low"}, - }) - if i >= 9 { // 最多 10 帧 - break - } + var skippedLarge int + for _, entry := range entries { + if len(blocks) >= nFrames { + break } + if !strings.HasSuffix(entry.Name(), ".jpg") { + continue + } + b, err := os.ReadFile(filepath.Join(tmpDir, entry.Name())) + if err != nil { + continue + } + if len(b) > 2*1024*1024 { + skippedLarge++ + continue + } + dURL := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(b) + blocks = append(blocks, pubsdk.ContentBlock{ + Type: "image_url", + ImageURL: &pubsdk.ImageURL{URL: dURL, Detail: "low"}, + }) } if len(blocks) == 0 { + if skippedLarge > 0 { + return fmt.Sprintf("提取到 %d 帧但全部超过 2MB 单帧上限,未注入", skippedLarge), nil + } return "视频中未提取到有效帧", nil } @@ -220,9 +246,39 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error p.sdk.SetToolBlocks(blocks) text := fmt.Sprintf("[已将 %d 个视频关键帧注入后续对话] %s", len(blocks), path) + if skippedLarge > 0 { + text += fmt.Sprintf("(另有 %d 帧超 2MB 已跳过)", skippedLarge) + } + if len(blocks) < nFrames { + text += fmt.Sprintf("(请求 %d 帧,实际只取到 %d 帧,视频可能过短)", nFrames, len(blocks)) + } return text, nil } +// probeDuration 用 ffprobe 取视频时长(秒),拿不到返回 0。 +// +// ffprobe 与 ffmpeg 同包同目录,所以从已找到的 ffmpeg 路径推导而非重新搜一遍。 +func probeDuration(ffmpegPath, videoPath string) float64 { + probe := "ffprobe" + if strings.Contains(ffmpegPath, "/") { + probe = filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") + if _, err := os.Stat(probe); err != nil { + probe = "ffprobe" + } + } + out, err := exec.Command(probe, "-v", "error", + "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", videoPath).Output() + if err != nil { + return 0 + } + d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil { + return 0 + } + return d +} + // ── listen ─────────────────────────────────────────────────────── func (p *Plugin) handleListen(args map[string]interface{}) (interface{}, error) { diff --git a/internal/sdk/llm_impl.go b/internal/sdk/llm_impl.go index dafbb4f..8a7b5df 100644 --- a/internal/sdk/llm_impl.go +++ b/internal/sdk/llm_impl.go @@ -139,6 +139,8 @@ func (l *llmImpl) ReloadFromConfig() error { ContextWindow: src.ContextWindow, MaxConcurrent: src.MaxConcurrent, Priority: src.Priority, + Vision: src.Vision, + Audio: src.Audio, }, l.lua, src.Name, src.Adapter) l.mgr.Register(src.Name, provider) if src.Adapter != "" { diff --git a/pkg/types/types.go b/pkg/types/types.go index b07080e..80192a1 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -107,6 +107,17 @@ type LLMSource struct { MaxConcurrent int `json:"max_concurrent,omitempty"` Priority int `json:"priority,omitempty"` ThinkingEnabled bool `json:"thinking_enabled,omitempty"` + + // Vision/Audio 声明该源能否真正处理多模态内容块。 + // + // 为何必须显式声明而不是探测:网关(如 llmsproxy)会把 image_url 块静默剥离后 + // 转发给纯文本上游,请求依然 200,​​带图与不带图的 prompt_tokens 完全相同。 + // 模型于是回答「我没有看到图片」,而内核以为注入成功——这正是 v1.0.0 之前 + // output_send 假成功的同一类缺陷:告诉调用方成功而实际未送达。 + // 探测需要额外一次真实调用且结果不稳定(取决于 AUTO 路由到哪个上游), + // 因此改为部署时声明。留空(false)按不支持处理,走文字回退链。 + Vision bool `json:"vision,omitempty"` + Audio bool `json:"audio,omitempty"` } type LLMConfig struct {