feat(multimodal): 内置多模态感知插件 + process.go 原生支持 tool message 多模态块

【新插件 internal/plugins/multimodal】
- see_picture(path): 读取本地图片/URL,base64 注入 image_url block,
  模型在下一轮 LLM 请求的 tool message 里直接看到图(1024×1024 图约 8500 token)。
  自动识别 MIME,限 3MB 防爆 context。
- see_video(path, frames): ffmpeg 提取关键帧,多帧作为 image_url block 注入。
  默认 4 帧,最大 10 帧,每帧限 2MB。
- listen(path): 读取音频文件,转为 audio_url block 注入,支持 mp3/wav/ogg/m4a。
  限 5MB。

【内核多模态 tool message 支持】
- agent/api 新增 ToolOutput 类型(为后续 handler 直接返回 blocks 预留)
- SDK 公共层新增 ContentBlock/ImageURL/AudioURL(OpenAI 多模态格式)
- IOManager 新增 SetToolBlocks/ConsumeToolBlocks(interface{} 避免循环依赖)
- PluginSDK.SetToolBlocks(blocks) 插件工具调用后注入 blocks
- ioAdapter 桥接 IOInjector.SetToolBlocks
- process.go 工具执行后消费 pending blocks → 追加到 tool message 的 Blocks 字段
  → MarshalJSON 输出 content 数组格式 → LLM 看到图/音频

【验证】
multimodal_see_picture 注入 1024×1024 PNG 后 llmsproxy 统计:
  prompt_tokens=44407(含 ~8500 image token),模型正确描述了图片内容。
This commit is contained in:
JianFeeeee
2026-08-27 08:39:21 +08:00
parent f0cdbdb030
commit b777322b95
8 changed files with 368 additions and 1 deletions

View File

@ -1230,6 +1230,15 @@ func getFloat(m map[string]interface{}, key string) float64 {
return 0
}
// ToolOutput 是工具 handler 返回的结构化结果,支持多模态内容。
// 返回 string 时等价于 ToolOutput{Text: result}。
type ToolOutput struct {
Text string `json:"text"` // LLM 看到的文字描述
Blocks []ContentBlock `json:"blocks,omitempty"` // 附加的多模态块image_url/audio_url追加到 tool message
}
func (t ToolOutput) String() string { return t.Text }
// truncateForLog 诊断日志用截断。
func truncateForLog(s string, n int) string {
if len(s) <= n {

View File

@ -98,6 +98,8 @@ type Agent struct {
// 模型思考模式thinking/reasoning
thinkingEnabled bool
pendingToolBlocks []interface{} // 插件工具通过 SetToolBlocks 注入的多模态块type agentAPI.ContentBlockprocess.go 消费后追加到 tool message
// 启动时间
startTime time.Time

View File

@ -12,6 +12,7 @@ import (
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
@ -311,7 +312,31 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
contentOnce = false
}
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: msgContent, ReasoningContent: resp.ReasoningContent, ToolCalls: []agentAPI.ToolCall{tc}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block
// process.go 拾起并追加到 tool message 的 content 数组OpenAI 多模态格式),
// 让下一轮 LLM 请求在 tool message 里看到图/音频。
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
var blocks []agentAPI.ContentBlock
for _, b := range rawBlocks {
if cb, ok := b.(pubsdk.ContentBlock); ok {
// 跨包类型拷贝pubsdk.ContentBlock → agentAPI.ContentBlock
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
if cb.ImageURL != nil {
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
}
if cb.AudioURL != nil {
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
}
blocks = append(blocks, block)
}
}
if len(blocks) > 0 {
toolMsg.Blocks = blocks
}
}
msgs = append(msgs, toolMsg)
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,

View File

@ -103,6 +103,11 @@ type IOManager struct {
outputCh chan *OutputEvent
nextReqID int64
inputChannels map[string]ChannelDef
// toolBlocks插件工具注入多模态内容块process.go 在下一条 tool message 时消费。
// 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。
toolBlocksMu sync.Mutex
toolPendingBlocks []interface{}
}
func NewIOManager() *IOManager {
@ -669,3 +674,20 @@ func (d *GPIODevice) Tools() []ToolDef {
func (d *GPIODevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"device": d.name, "tool": tool, "status": "ok"}, nil
}
// SetToolBlocks 插件工具调用时注入多模态内容块image_url/audio_url 等),
// 下一条 tool message 追加这些块到 content 数组OpenAI 多模态格式)。
func (m *IOManager) SetToolBlocks(blocks []interface{}) {
m.toolBlocksMu.Lock()
m.toolPendingBlocks = blocks
m.toolBlocksMu.Unlock()
}
// ConsumeToolBlocks 返回并清空 pending blocksprocess.go 在 append tool message 时调用。
func (m *IOManager) ConsumeToolBlocks() []interface{} {
m.toolBlocksMu.Lock()
blocks := m.toolPendingBlocks
m.toolPendingBlocks = nil
m.toolBlocksMu.Unlock()
return blocks
}