feat: add multimodal vision support across all channels
Convert image handling from file paths to base64 data URLs and pass them through the message pipeline to the LLM using OpenAI's multi-part content format (text + image_url). This enables vision capabilities for all supported channels (Discord, Telegram, LINE, Slack, WebSocket). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
be408cb8b9
commit
f6d32bf951
10 changed files with 141 additions and 73 deletions
|
|
@ -220,10 +220,14 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
||||||
|
|
||||||
messages = append(messages, history...)
|
messages = append(messages, history...)
|
||||||
|
|
||||||
messages = append(messages, providers.Message{
|
userMsg := providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: currentMessage,
|
Content: currentMessage,
|
||||||
})
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
userMsg.Media = media
|
||||||
|
}
|
||||||
|
messages = append(messages, userMsg)
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,14 +49,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 // Base64 data URLs for 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createToolRegistry creates a tool registry with common tools.
|
// createToolRegistry creates a tool registry with common tools.
|
||||||
|
|
@ -310,6 +311,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
|
Media: msg.Media,
|
||||||
DefaultResponse: "I've completed processing but have no response to give.",
|
DefaultResponse: "I've completed processing but have no response to give.",
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
|
@ -396,7 +398,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
||||||
history,
|
history,
|
||||||
summary,
|
summary,
|
||||||
opts.UserMessage,
|
opts.UserMessage,
|
||||||
nil,
|
opts.Media,
|
||||||
opts.Channel,
|
opts.Channel,
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -353,12 +353,15 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
"url": attachment.URL,
|
"url": attachment.URL,
|
||||||
"filename": attachment.Filename,
|
"filename": attachment.Filename,
|
||||||
})
|
})
|
||||||
mediaPaths = append(mediaPaths, attachment.URL)
|
|
||||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mediaPaths = append(mediaPaths, attachment.URL)
|
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
if localPath != "" {
|
||||||
|
localFiles = append(localFiles, localPath)
|
||||||
|
if dataURL := utils.EncodeFileToDataURL(localPath); dataURL != "" {
|
||||||
|
mediaPaths = append(mediaPaths, dataURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,10 +369,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if content == "" {
|
|
||||||
content = "[media only]"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.DebugCF("discord", "Received message", map[string]any{
|
logger.DebugCF("discord", "Received message", map[string]any{
|
||||||
"sender_name": senderName,
|
"sender_name": senderName,
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
|
|
|
||||||
|
|
@ -331,8 +331,9 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
localPath := c.downloadContent(msg.ID, "image.jpg")
|
localPath := c.downloadContent(msg.ID, "image.jpg")
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
localFiles = append(localFiles, localPath)
|
localFiles = append(localFiles, localPath)
|
||||||
mediaPaths = append(mediaPaths, localPath)
|
if dataURL := utils.EncodeFileToDataURL(localPath); dataURL != "" {
|
||||||
content = "[image]"
|
mediaPaths = append(mediaPaths, dataURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "audio":
|
case "audio":
|
||||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
||||||
|
|
@ -356,7 +357,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
content = fmt.Sprintf("[%s]", msg.Type)
|
content = fmt.Sprintf("[%s]", msg.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" && len(mediaPaths) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
localFiles = append(localFiles, localPath)
|
localFiles = append(localFiles, localPath)
|
||||||
mediaPaths = append(mediaPaths, localPath)
|
|
||||||
|
|
||||||
if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
|
if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||||
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
|
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
|
||||||
|
|
@ -264,13 +263,15 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
} else {
|
} else {
|
||||||
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
||||||
}
|
}
|
||||||
|
} else if dataURL := utils.EncodeFileToDataURL(localPath); dataURL != "" {
|
||||||
|
mediaPaths = append(mediaPaths, dataURL)
|
||||||
} else {
|
} else {
|
||||||
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" && len(mediaPaths) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -244,11 +244,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||||
if photoPath != "" {
|
if photoPath != "" {
|
||||||
localFiles = append(localFiles, photoPath)
|
localFiles = append(localFiles, photoPath)
|
||||||
mediaPaths = append(mediaPaths, photoPath)
|
if dataURL := utils.EncodeFileToDataURL(photoPath); dataURL != "" {
|
||||||
if content != "" {
|
mediaPaths = append(mediaPaths, dataURL)
|
||||||
content += "\n"
|
|
||||||
}
|
}
|
||||||
content += "[image: photo]"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,7 +309,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if content == "" {
|
if content == "" && len(mediaPaths) == 0 {
|
||||||
content = "[empty message]"
|
content = "[empty message]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,9 @@ package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
@ -235,19 +232,10 @@ func (c *WebSocketChannel) readPump(conn *websocket.Conn, clientID, chatID strin
|
||||||
content := incoming.Content
|
content := incoming.Content
|
||||||
var media []string
|
var media []string
|
||||||
|
|
||||||
// Save images to temp files (same pattern as Telegram).
|
// Convert base64 images directly to data URLs.
|
||||||
for i, imgData := range incoming.Images {
|
for _, imgData := range incoming.Images {
|
||||||
path, err := c.saveImage(imgData)
|
dataURL := "data:image/png;base64," + imgData
|
||||||
if err != nil {
|
media = append(media, dataURL)
|
||||||
logger.ErrorCF("websocket", "Failed to save image", map[string]interface{}{
|
|
||||||
"client_id": clientID,
|
|
||||||
"index": i,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
media = append(media, path)
|
|
||||||
content += fmt.Sprintf("\n[image: photo_%d]", i)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("websocket", "Received message", map[string]interface{}{
|
logger.DebugCF("websocket", "Received message", map[string]interface{}{
|
||||||
|
|
@ -260,30 +248,3 @@ func (c *WebSocketChannel) readPump(conn *websocket.Conn, clientID, chatID strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WebSocketChannel) saveImage(base64Data string) (string, error) {
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
// Try URL-safe base64.
|
|
||||||
data, err = base64.URLEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode base64: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpDir := filepath.Join(os.TempDir(), "picoclaw", "ws_images")
|
|
||||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.CreateTemp(tmpDir, "ws_img_*.png")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
if _, err := f.Write(data); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to write image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Name(), nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||||
|
|
||||||
requestBody := map[string]interface{}{
|
requestBody := map[string]interface{}{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": p.buildAPIMessages(messages),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
|
|
@ -124,6 +124,51 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||||
return p.parseResponse(body)
|
return p.parseResponse(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildAPIMessages converts internal Message slice to OpenAI API format.
|
||||||
|
// If a user message has Media, content becomes an array of text + image_url objects.
|
||||||
|
func (p *HTTPProvider) buildAPIMessages(messages []Message) []map[string]interface{} {
|
||||||
|
result := make([]map[string]interface{}, 0, len(messages))
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"role": msg.Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only user messages with media get the array-style content
|
||||||
|
if msg.Role == "user" && len(msg.Media) > 0 {
|
||||||
|
parts := make([]map[string]interface{}, 0, 1+len(msg.Media))
|
||||||
|
if msg.Content != "" {
|
||||||
|
parts = append(parts, map[string]interface{}{
|
||||||
|
"type": "text",
|
||||||
|
"text": msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, dataURL := range msg.Media {
|
||||||
|
parts = append(parts, map[string]interface{}{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": map[string]string{
|
||||||
|
"url": dataURL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m["content"] = parts
|
||||||
|
} else {
|
||||||
|
m["content"] = msg.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
m["tool_calls"] = msg.ToolCalls
|
||||||
|
}
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
m["tool_call_id"] = msg.ToolCallID
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
|
func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
var apiResponse struct {
|
var apiResponse struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type UsageInfo struct {
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Media []string `json:"media,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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -12,6 +13,61 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxImageFileSize = 50 * 1024 * 1024 // 50MB
|
||||||
|
|
||||||
|
// EncodeFileToDataURL reads a local file and returns a base64 data URL string.
|
||||||
|
// Supported: JPEG, PNG, WEBP, GIF. Max 50MB.
|
||||||
|
// Returns empty string on error.
|
||||||
|
func EncodeFileToDataURL(path string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
var mime string
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
mime = "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
mime = "image/png"
|
||||||
|
case ".webp":
|
||||||
|
mime = "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
mime = "image/gif"
|
||||||
|
default:
|
||||||
|
logger.WarnCF("media", "Unsupported image extension", map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"ext": ext,
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("media", "Failed to stat image file", map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if info.Size() > maxImageFileSize {
|
||||||
|
logger.WarnCF("media", "Image file too large, skipping", map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"size_mb": info.Size() / (1024 * 1024),
|
||||||
|
"max_mb": maxImageFileSize / (1024 * 1024),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("media", "Failed to read image file", map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
return "data:" + mime + ";base64," + encoded
|
||||||
|
}
|
||||||
|
|
||||||
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
|
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
|
||||||
func IsAudioFile(filename, contentType string) bool {
|
func IsAudioFile(filename, contentType string) bool {
|
||||||
audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
|
audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue