mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e671a8c082 | |||
| dcaea64439 | |||
| 3907347cac | |||
| 09071dc235 |
@ -193,6 +193,8 @@ internal/
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.0.1** — 多模态 bugfix。插件 ABI/协议未变,1.0.0 编出的 `plugin.bin` 无需重编。修三类缺陷:(1)**看图假成功**——媒体块挂在 tool message 上不被模型当作可视内容(实测同一张图:tool message 0/3 读到、独立 user message 3/3),改为另起一条紧随其后的 user message 承载,落实插件文案一直在说的「注入后续对话」;(2)**新增多模态能力声明与回退链**——`core.llm.sources.<name>.vision/.audio` 声明源能否真正处理媒体(网关会静默剥离 `image_url` 后仍返回 200,带图与不带图 prompt_tokens 完全相同),不支持时自动走视觉源转写成文字,并落实了 `core.input_processing.image.fallback_provider` 这批早已注册却从未被读取的配置项;(3)**`see_video` 帧数语义反了**——`fps=1/N` 是频率不是数量,20s 视频请求 10 帧只得 2 帧、请求 1 帧反得 20 帧,改为 `ffprobe` 取时长 + `fps=N/时长` + `-frames:v` 硬封顶。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。**
|
||||
@ -218,7 +220,7 @@ internal/
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- Windows:`HomeAgent_v1.0.1_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
|
||||
@ -179,6 +179,8 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.0.1** — Multimodal bugfix. The plugin ABI/protocol is unchanged, so `plugin.bin` artifacts built for 1.0.0 need no rebuild. Three defects fixed: (1) **vision silently failing**—media blocks attached to a tool message are not treated as viewable content by the model (measured on one image: 0/3 read from a tool message, 3/3 from a standalone user message); media now rides its own user message placed immediately after, which is what the plugin's own wording ("injected into the following conversation") always claimed; (2) **new multimodal capability declaration + fallback chain**—`core.llm.sources.<name>.vision/.audio` declares whether a source can genuinely process media (a gateway may strip `image_url` and still return 200, with identical prompt_tokens with and without the image); when it cannot, media is transcribed to text via a vision-capable source, finally wiring up the long-registered but never-read `core.input_processing.image.fallback_provider` settings; (3) **`see_video` frame-count semantics were inverted**—`fps=1/N` is a *rate*, not a count, so a 20s video yielded 2 frames when 10 were requested and 20 frames when 1 was requested; now `ffprobe` measures duration and the filter becomes `fps=N/duration` with `-frames:v` as a hard cap.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
|
||||
@ -204,7 +206,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||
|
||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||
- Windows: `HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Windows: `HomeAgent_v1.0.1_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -27,10 +27,19 @@ COMPONENT="${2:-all}"
|
||||
case "$TARGET" in
|
||||
native) GOOS="" GOARCH="" ;;
|
||||
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
|
||||
# arm64 刻意不设 CXX:设了会让 Go 用 aarch64 的 g++ 去链接,
|
||||
# 而它对 host 产生的 .o 报 "file format not recognized"。
|
||||
# gojieba 的 C++ 源仍由 CC 对应的 gcc 驱动编译(gcc 能编 C++)。
|
||||
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" ;;
|
||||
# arm64 必须同时给 CXX:gojieba 是 C++,缺 CXX 时 cgo 用宿主 g++ 编出
|
||||
# x86-64 的 .o,链接时报 "Relocations in generic ELF (EM: 183)"(183 = aarch64)。
|
||||
#
|
||||
# 此处曾有一条注释写着「arm64 刻意不设 CXX」,理由是设了会报
|
||||
# "file format not recognized"。那个判断是错的:那个报错的真因是
|
||||
# cmd/{homed,waiter}/*.syso(x86-64 COFF Windows 资源对象)被链进了目标,
|
||||
# 与 CXX 无关。四组对照:
|
||||
# syso 在 + 无 CXX → Relocations in generic ELF (EM: 183)
|
||||
# syso 在 + 有 CXX → 000000.o: file format not recognized
|
||||
# syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
|
||||
# syso 隐藏 + 有 CXX → 成功,ELF aarch64
|
||||
# 本脚本的 hide_syso_for_target 已处理前一个条件,这里补上后一个。
|
||||
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" CXX="${CXX:-aarch64-linux-gnu-g++}" ;;
|
||||
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
|
||||
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
|
||||
# Windows 必须同时给 CXX:gojieba 是 C++,缺 CXX 时 cgo 回退到宿主 g++,
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
||||
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
||||
!ifndef PRODUCT_VERSION
|
||||
!define PRODUCT_VERSION "1.0.0"
|
||||
!define PRODUCT_VERSION "1.0.1"
|
||||
!endif
|
||||
|
||||
!if "${VARIANT}" == "full"
|
||||
|
||||
@ -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.<name>.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
|
||||
|
||||
362
internal/agent/core/modalfallback.go
Normal file
362
internal/agent/core/modalfallback.go
Normal file
@ -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.<name>.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.<name>.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
|
||||
}
|
||||
428
internal/agent/core/modalfallback_test.go
Normal file
428
internal/agent/core/modalfallback_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -313,10 +313,21 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: msgContent, ReasoningContent: resp.ReasoningContent, ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
|
||||
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block,
|
||||
// process.go 拾起并追加到 tool message 的 content 数组(OpenAI 多模态格式),
|
||||
// 让下一轮 LLM 请求在 tool message 里看到图/音频。
|
||||
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
|
||||
//
|
||||
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
|
||||
// 这也是插件文案一直在说的「注入后续对话」。
|
||||
// 为何不能挂 tool message:同一张图、同一模型、三轮实测——
|
||||
// 图在 user message → 3/3 读到
|
||||
// 图在 tool message → 0/3(模型答「没能读到这张图」)
|
||||
// tool 纯文本 + 后接 user → 3/3 读到
|
||||
// tool message 那轮 prompt_tokens 反而更高(7967 vs 7089),base64 确实
|
||||
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
|
||||
//
|
||||
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
|
||||
// 返回 200,模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
|
||||
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
var mediaMsg *agentAPI.Message
|
||||
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
|
||||
var blocks []agentAPI.ContentBlock
|
||||
for _, b := range rawBlocks {
|
||||
@ -333,10 +344,30 @@ 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 {
|
||||
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
|
||||
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
|
||||
mediaBlocks := append([]agentAPI.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
|
||||
}}, native...)
|
||||
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
|
||||
} else if fallbackText != "" {
|
||||
// 回退链已把媒体转写成文字:并进 tool message 的纯文本 content,
|
||||
// 不再另起消息(文字在 tool message 里本来就能被读到)。
|
||||
toolMsg.Content = result + "\n\n" + fallbackText
|
||||
result = toolMsg.Content
|
||||
if len(toolResults) > 0 {
|
||||
toolResults[len(toolResults)-1].Output = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, toolMsg)
|
||||
if mediaMsg != nil {
|
||||
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
|
||||
msgs = append(msgs, *mediaMsg)
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
|
||||
@ -188,6 +188,8 @@ var sourceFieldDefs = []struct {
|
||||
{"adapter_path", "string", "适配器路径"},
|
||||
{"max_concurrent", "int", "并发上限"},
|
||||
{"priority", "int", "AUTO 优先级(大者优先)"},
|
||||
{"vision", "bool", "支持图片"},
|
||||
{"audio", "bool", "支持音频"},
|
||||
}
|
||||
|
||||
// registerSourceDefs 注册 core.llm.sources.<name>.* 的 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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,10 @@ var (
|
||||
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
||||
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
||||
// 用新版 plugindev 重编),故跃到主版本号。
|
||||
Version = "1.0.0"
|
||||
//
|
||||
// 1.0.1:多模态修复。仅内核与内置插件改动,插件 ABI/协议未变,
|
||||
// 1.0.0 编出的 plugin.bin 无需重编。
|
||||
Version = "1.0.1"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user