feat: add multimodal image support for vision-capable LLMs
Enable channels (e.g. Telegram) to pass user-attached images through to vision-capable LLMs. Previously, downloaded images were stored locally but never forwarded to the provider — the media pipeline was broken at the agent layer. Changes: - Add ImageURL type and ContentParts field to protocoltypes.Message - New providers/media.go: LoadMediaAsContentParts converts local files to base64 data URLs (supports jpg/png/gif/webp, ≤5MB per image) - Agent loop resolves media:// refs to local paths and passes them through processOptions to BuildMessages - BuildMessages attaches ContentParts (text + image_url blocks) when media is present, preserving pure-text behavior when absent - OpenAI compat: Content field becomes `any` (string or []contentPart) following the OpenAI Vision API spec - Anthropic: ContentParts mapped to NewImageBlockBase64 via parseDataURL Backward compatible — no existing behavior changes when no media is attached. The Content (string) field is always preserved as fallback.
This commit is contained in:
parent
de2ccb5da4
commit
0207584315
6 changed files with 210 additions and 18 deletions
|
|
@ -466,10 +466,24 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
|
|
||||||
// Add current user message
|
// Add current user message
|
||||||
if strings.TrimSpace(currentMessage) != "" {
|
if strings.TrimSpace(currentMessage) != "" {
|
||||||
messages = append(messages, providers.Message{
|
userMsg := providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: currentMessage,
|
Content: currentMessage,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Attach image content parts for multimodal messages
|
||||||
|
if len(media) > 0 {
|
||||||
|
imageParts := providers.LoadMediaAsContentParts(media)
|
||||||
|
if len(imageParts) > 0 {
|
||||||
|
// Build ContentParts: text block first, then image blocks
|
||||||
|
userMsg.ContentParts = append(
|
||||||
|
[]providers.ContentBlock{{Type: "text", Text: currentMessage}},
|
||||||
|
imageParts...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, userMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,15 @@ type AgentLoop struct {
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
type processOptions struct {
|
type processOptions struct {
|
||||||
SessionKey string // Session identifier for history/context
|
SessionKey string // Session identifier for history/context
|
||||||
Channel string // Target channel for tool execution
|
Channel string // Target channel for tool execution
|
||||||
ChatID string // Target chat ID for tool execution
|
ChatID string // Target chat ID for tool execution
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
DefaultResponse string // Response when LLM returns empty
|
Media []string // Local file paths for attached images
|
||||||
EnableSummary bool // Whether to trigger summarization
|
DefaultResponse string // Response when LLM returns empty
|
||||||
SendResponse bool // Whether to send response via bus
|
EnableSummary bool // Whether to trigger summarization
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
SendResponse bool // Whether to send response via bus
|
||||||
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
||||||
|
|
@ -492,11 +493,27 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
"matched_by": route.MatchedBy,
|
"matched_by": route.MatchedBy,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Resolve media refs to local file paths for multimodal support
|
||||||
|
var mediaPaths []string
|
||||||
|
if len(msg.Media) > 0 && al.mediaStore != nil {
|
||||||
|
for _, ref := range msg.Media {
|
||||||
|
if localPath, err := al.mediaStore.Resolve(ref); err == nil {
|
||||||
|
mediaPaths = append(mediaPaths, localPath)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
|
Media: mediaPaths,
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
|
@ -603,7 +620,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
history,
|
history,
|
||||||
summary,
|
summary,
|
||||||
opts.UserMessage,
|
opts.UserMessage,
|
||||||
nil,
|
opts.Media,
|
||||||
opts.Channel,
|
opts.Channel,
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,24 @@ func buildParams(
|
||||||
anthropicMessages = append(anthropicMessages,
|
anthropicMessages = append(anthropicMessages,
|
||||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||||
)
|
)
|
||||||
|
} else if len(msg.ContentParts) > 0 {
|
||||||
|
// Multimodal message with text + images
|
||||||
|
var blocks []anthropic.ContentBlockParamUnion
|
||||||
|
for _, part := range msg.ContentParts {
|
||||||
|
switch part.Type {
|
||||||
|
case "text":
|
||||||
|
blocks = append(blocks, anthropic.NewTextBlock(part.Text))
|
||||||
|
case "image_url":
|
||||||
|
if part.ImageURL != nil {
|
||||||
|
if mediaType, b64Data, ok := parseDataURL(part.ImageURL.URL); ok {
|
||||||
|
blocks = append(blocks, anthropic.NewImageBlockBase64(mediaType, b64Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(blocks) > 0 {
|
||||||
|
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(blocks...))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
anthropicMessages = append(anthropicMessages,
|
anthropicMessages = append(anthropicMessages,
|
||||||
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
||||||
|
|
@ -273,3 +291,24 @@ func normalizeBaseURL(apiBase string) string {
|
||||||
|
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseDataURL extracts the media type and base64 data from a data URL.
|
||||||
|
// Expected format: "data:<mediaType>;base64,<data>"
|
||||||
|
// Returns (mediaType, base64Data, ok).
|
||||||
|
func parseDataURL(url string) (string, string, bool) {
|
||||||
|
if !strings.HasPrefix(url, "data:") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// Strip "data:" prefix
|
||||||
|
rest := url[5:]
|
||||||
|
semicolon := strings.Index(rest, ";base64,")
|
||||||
|
if semicolon < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
mediaType := rest[:semicolon]
|
||||||
|
b64Data := rest[semicolon+8:] // len(";base64,") == 8
|
||||||
|
if mediaType == "" || b64Data == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return mediaType, b64Data, true
|
||||||
|
}
|
||||||
|
|
|
||||||
87
pkg/providers/media.go
Normal file
87
pkg/providers/media.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxImageFileSize is the maximum raw file size for inline base64 images (5 MB).
|
||||||
|
const maxImageFileSize = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
// supportedImageExts maps lowercase file extensions to MIME types.
|
||||||
|
var supportedImageExts = map[string]string{
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMediaAsContentParts converts a list of local file paths (or URLs)
|
||||||
|
// into ContentBlock slices suitable for multimodal LLM requests.
|
||||||
|
//
|
||||||
|
// - Local files are read, base64-encoded, and wrapped in a data URL.
|
||||||
|
// - http(s) URLs are passed through as image_url blocks directly.
|
||||||
|
// - Non-image files and files exceeding maxImageFileSize are skipped.
|
||||||
|
func LoadMediaAsContentParts(paths []string) []protocoltypes.ContentBlock {
|
||||||
|
var parts []protocoltypes.ContentBlock
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
// HTTP(S) URL — pass through
|
||||||
|
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
|
||||||
|
parts = append(parts, protocoltypes.ContentBlock{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &protocoltypes.ImageURL{URL: p},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local file
|
||||||
|
ext := strings.ToLower(filepath.Ext(p))
|
||||||
|
mimeType, ok := supportedImageExts[ext]
|
||||||
|
if !ok {
|
||||||
|
logger.DebugCF("media", "Skipping non-image file", map[string]any{"path": p, "ext": ext})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("media", "Cannot stat media file", map[string]any{"path": p, "error": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Size() > maxImageFileSize {
|
||||||
|
logger.WarnCF("media", "Skipping oversized image", map[string]any{
|
||||||
|
"path": p,
|
||||||
|
"size": info.Size(),
|
||||||
|
"max_bytes": maxImageFileSize,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("media", "Cannot read media file", map[string]any{"path": p, "error": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(data))
|
||||||
|
parts = append(parts, protocoltypes.ContentBlock{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &protocoltypes.ImageURL{URL: dataURL},
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.DebugCF("media", "Loaded image", map[string]any{
|
||||||
|
"path": p,
|
||||||
|
"mime_type": mimeType,
|
||||||
|
"size": info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
@ -285,12 +285,21 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openaiContentPart represents a content part in the OpenAI Vision API format.
|
||||||
|
type openaiContentPart struct {
|
||||||
|
Type string `json:"type"` // "text" or "image_url"
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *protocoltypes.ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
||||||
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
// It mirrors protocoltypes.Message but omits SystemParts/ContentParts,
|
||||||
// internal field that would be unknown to third-party endpoints.
|
// which are internal fields that would be unknown to third-party endpoints.
|
||||||
|
// Content is `any` to support both string (text-only) and []openaiContentPart
|
||||||
|
// (multimodal) as required by the OpenAI Vision API.
|
||||||
type openaiMessage struct {
|
type openaiMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content any `json:"content"`
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
|
@ -299,12 +308,30 @@ type openaiMessage struct {
|
||||||
// stripSystemParts converts []Message to []openaiMessage, dropping the
|
// stripSystemParts converts []Message to []openaiMessage, dropping the
|
||||||
// SystemParts field so it doesn't leak into the JSON payload sent to
|
// SystemParts field so it doesn't leak into the JSON payload sent to
|
||||||
// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
|
// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
|
||||||
|
// When ContentParts is present, Content is serialized as an array of
|
||||||
|
// content parts (OpenAI Vision API format).
|
||||||
func stripSystemParts(messages []Message) []openaiMessage {
|
func stripSystemParts(messages []Message) []openaiMessage {
|
||||||
out := make([]openaiMessage, len(messages))
|
out := make([]openaiMessage, len(messages))
|
||||||
for i, m := range messages {
|
for i, m := range messages {
|
||||||
|
var content any = m.Content
|
||||||
|
|
||||||
|
// Convert multimodal ContentParts to OpenAI Vision format
|
||||||
|
if len(m.ContentParts) > 0 {
|
||||||
|
parts := make([]openaiContentPart, 0, len(m.ContentParts))
|
||||||
|
for _, p := range m.ContentParts {
|
||||||
|
switch p.Type {
|
||||||
|
case "text":
|
||||||
|
parts = append(parts, openaiContentPart{Type: "text", Text: p.Text})
|
||||||
|
case "image_url":
|
||||||
|
parts = append(parts, openaiContentPart{Type: "image_url", ImageURL: p.ImageURL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = parts
|
||||||
|
}
|
||||||
|
|
||||||
out[i] = openaiMessage{
|
out[i] = openaiMessage{
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: m.Content,
|
Content: content,
|
||||||
ReasoningContent: m.ReasoningContent,
|
ReasoningContent: m.ReasoningContent,
|
||||||
ToolCalls: m.ToolCalls,
|
ToolCalls: m.ToolCalls,
|
||||||
ToolCallID: m.ToolCallID,
|
ToolCallID: m.ToolCallID,
|
||||||
|
|
|
||||||
|
|
@ -53,18 +53,26 @@ type CacheControl struct {
|
||||||
Type string `json:"type"` // "ephemeral"
|
Type string `json:"type"` // "ephemeral"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentBlock represents a structured segment of a system message.
|
// ImageURL holds a URL for an image content block.
|
||||||
|
// The URL can be an http(s) link or a base64 data URL (e.g. "data:image/jpeg;base64,...").
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlock represents a structured segment of a message.
|
||||||
// Adapters that understand SystemParts can use these blocks to set
|
// Adapters that understand SystemParts can use these blocks to set
|
||||||
// per-block cache control (e.g. Anthropic's cache_control: ephemeral).
|
// per-block cache control (e.g. Anthropic's cache_control: ephemeral).
|
||||||
type ContentBlock struct {
|
type ContentBlock struct {
|
||||||
Type string `json:"type"` // "text"
|
Type string `json:"type"` // "text" or "image_url"
|
||||||
Text string `json:"text"`
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
CacheControl *CacheControl `json:"cache_control,omitempty"`
|
CacheControl *CacheControl `json:"cache_control,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ContentParts []ContentBlock `json:"content_parts,omitempty"` // multimodal content (text + images)
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
|
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue