feat: implement file/image support in chat channels (#61)

Implements file and image support for chat channels, following the
pattern from nanobot implementation. Images are downloaded to local
temp files, base64-encoded, and sent as multipart content to LLMs
with vision capabilities.

Changes:
- Modified Message.Content from string to interface{} to support
  multipart content (text + images)
- Added buildUserContent() method for base64 encoding images
- Updated all providers to handle multipart content
- Added IsImageFile() and improved GetMimeType() utilities
- Implemented image download in Discord, LINE, and Slack channels
- Fixed critical bug where media paths weren't reaching LLM
- Consolidated duplicate contentToString* functions
- Proper file cleanup after base64 encoding

Tested with Discord and Telegram channels and confirmed working.

Fixes #61

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
tttocklll 2026-02-15 17:20:40 +09:00
parent 9a3f3611c3
commit 77a4bbafe4
14 changed files with 324 additions and 116 deletions

7
go.mod
View file

@ -19,8 +19,6 @@ require (
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
) )
require ( require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
@ -28,9 +26,9 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.1.23 github.com/github/copilot-sdk/go v0.1.23
github.com/google/jsonschema-go v0.4.2 // indirect github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@ -47,5 +45,4 @@ require (
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
) )

6
go.sum
View file

@ -36,8 +36,8 @@ github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQ
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

View file

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"encoding/base64"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@ -12,6 +13,7 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
) )
type ContextBuilder struct { type ContextBuilder struct {
@ -157,6 +159,65 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return result return result
} }
// buildUserContent builds user message content with optional base64-encoded images.
// Returns either a string (text-only) or []interface{} (text + images).
func (cb *ContextBuilder) buildUserContent(text string, media []string) interface{} {
if len(media) == 0 {
return text
}
// Build multipart content with images
content := []interface{}{}
for _, mediaPath := range media {
mimeType := utils.GetMimeType(mediaPath)
// Only process image files
if !strings.HasPrefix(mimeType, "image/") {
continue
}
// Read and encode image
data, err := os.ReadFile(mediaPath)
if err != nil {
logger.ErrorCF("agent", "Failed to read media file",
map[string]interface{}{
"path": mediaPath,
"error": err.Error(),
})
continue
}
b64 := base64.StdEncoding.EncodeToString(data)
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64)
content = append(content, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": imageURL,
},
})
// Clean up the temporary file after encoding
os.Remove(mediaPath)
}
// Add text content
if text != "" {
content = append(content, map[string]interface{}{
"type": "text",
"text": text,
})
}
// If no images were successfully processed, return text only
if len(content) == 0 || (len(content) == 1 && text != "") {
return text
}
return content
}
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message { func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
messages := []providers.Message{} messages := []providers.Message{}
@ -207,9 +268,11 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
messages = append(messages, history...) messages = append(messages, history...)
// Build user content with optional media
userContent := cb.buildUserContent(currentMessage, media)
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
Role: "user", Role: "user",
Content: currentMessage, Content: userContent,
}) })
return messages return messages

View file

@ -46,14 +46,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 // Media file paths (images, audio, etc.)
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.
@ -256,6 +257,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
"sender_id": msg.SenderID, "sender_id": msg.SenderID,
"session_key": msg.SessionKey, "session_key": msg.SessionKey,
"media_count": len(msg.Media),
}) })
// Route system messages to processSystemMessage // Route system messages to processSystemMessage
@ -269,6 +271,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,
@ -355,7 +358,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,
) )
@ -425,6 +428,14 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
// Build tool definitions // Build tool definitions
providerToolDefs := al.tools.ToProviderDefs() providerToolDefs := al.tools.ToProviderDefs()
// Calculate system prompt length
systemPromptLen := 0
if len(messages) > 0 {
if s, ok := messages[0].Content.(string); ok {
systemPromptLen = len(s)
}
}
// Log LLM request details // Log LLM request details
logger.DebugCF("agent", "LLM request", logger.DebugCF("agent", "LLM request",
map[string]interface{}{ map[string]interface{}{
@ -434,7 +445,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
"tools_count": len(providerToolDefs), "tools_count": len(providerToolDefs),
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"system_prompt_len": len(messages[0].Content), "system_prompt_len": systemPromptLen,
}) })
// Log full messages (detailed) // Log full messages (detailed)
@ -640,9 +651,15 @@ func formatMessagesForLog(messages []providers.Message) string {
} }
} }
} }
if msg.Content != "" { if msg.Content != nil {
content := utils.Truncate(msg.Content, 200) contentStr := ""
result += fmt.Sprintf(" Content: %s\n", content) if s, ok := msg.Content.(string); ok {
contentStr = s
}
if contentStr != "" {
content := utils.Truncate(contentStr, 200)
result += fmt.Sprintf(" Content: %s\n", content)
}
} }
if msg.ToolCallID != "" { if msg.ToolCallID != "" {
result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID) result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID)
@ -698,7 +715,11 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
continue continue
} }
// Estimate tokens for this message // Estimate tokens for this message
msgTokens := len(m.Content) / 4 contentLen := 0
if s, ok := m.Content.(string); ok {
contentLen = len(s)
}
msgTokens := contentLen / 4
if msgTokens > maxMessageTokens { if msgTokens > maxMessageTokens {
omitted = true omitted = true
continue continue
@ -755,7 +776,11 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
} }
prompt += "\nCONVERSATION:\n" prompt += "\nCONVERSATION:\n"
for _, m := range batch { for _, m := range batch {
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content) contentStr := ""
if s, ok := m.Content.(string); ok {
contentStr = s
}
prompt += fmt.Sprintf("%s: %s\n", m.Role, contentStr)
} }
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{ response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
@ -775,7 +800,11 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
func (al *AgentLoop) estimateTokens(messages []providers.Message) int { func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
total := 0 total := 0
for _, m := range messages { for _, m := range messages {
total += utf8.RuneCountInString(m.Content) / 3 contentStr := ""
if s, ok := m.Content.(string); ok {
contentStr = s
}
total += utf8.RuneCountInString(contentStr) / 3
} }
return total return total
} }

View file

@ -3,7 +3,6 @@ package channels
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"time" "time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@ -156,27 +155,16 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content := m.Content content := m.Content
mediaPaths := make([]string, 0, len(m.Attachments)) mediaPaths := make([]string, 0, len(m.Attachments))
localFiles := make([]string, 0, len(m.Attachments)) // Note: Files will be cleaned up by context.buildUserContent after base64 encoding
// 确保临时文件在函数返回时被清理
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
}
}
}()
for _, attachment := range m.Attachments { for _, attachment := range m.Attachments {
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType) isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
isImage := utils.IsImageFile(attachment.Filename, attachment.ContentType)
if isAudio { if isAudio {
localPath := c.downloadAttachment(attachment.URL, attachment.Filename) localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" { if localPath != "" {
localFiles = append(localFiles, localPath) mediaPaths = append(mediaPaths, localPath)
transcribedText := "" transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() { if c.transcriber != nil && c.transcriber.IsAvailable() {
@ -205,12 +193,24 @@ 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 (download failed)]", attachment.Filename))
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) }
} else if isImage {
// Download image for vision processing
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
mediaPaths = append(mediaPaths, localPath)
content = appendContent(content, fmt.Sprintf("[image: %s]", attachment.Filename))
} else {
logger.WarnCF("discord", "Failed to download image attachment", map[string]any{
"url": attachment.URL,
"filename": attachment.Filename,
})
content = appendContent(content, fmt.Sprintf("[image: %s (download failed)]", attachment.Filename))
} }
} else { } else {
mediaPaths = append(mediaPaths, attachment.URL) // Non-image, non-audio attachment (documents, etc.)
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.Filename))
} }
} }

View file

@ -10,7 +10,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -307,18 +306,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
var content string var content string
var mediaPaths []string var mediaPaths []string
localFiles := []string{} // Note: Files will be cleaned up by context.buildUserContent after base64 encoding
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
"file": file,
"error": err.Error(),
})
}
}
}()
switch msg.Type { switch msg.Type {
case "text": case "text":
@ -330,21 +318,18 @@ func (c *LINEChannel) processEvent(event lineEvent) {
case "image": case "image":
localPath := c.downloadContent(msg.ID, "image.jpg") localPath := c.downloadContent(msg.ID, "image.jpg")
if localPath != "" { if localPath != "" {
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, localPath) mediaPaths = append(mediaPaths, localPath)
content = "[image]" content = "[image]"
} }
case "audio": case "audio":
localPath := c.downloadContent(msg.ID, "audio.m4a") localPath := c.downloadContent(msg.ID, "audio.m4a")
if localPath != "" { if localPath != "" {
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, localPath) mediaPaths = append(mediaPaths, localPath)
content = "[audio]" content = "[audio]"
} }
case "video": case "video":
localPath := c.downloadContent(msg.ID, "video.mp4") localPath := c.downloadContent(msg.ID, "video.mp4")
if localPath != "" { if localPath != "" {
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, localPath) mediaPaths = append(mediaPaths, localPath)
content = "[video]" content = "[video]"
} }

View file

@ -3,7 +3,6 @@ package channels
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -230,19 +229,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content = c.stripBotMention(content) content = c.stripBotMention(content)
var mediaPaths []string var mediaPaths []string
localFiles := []string{} // 跟踪需要清理的本地文件 // Note: Files will be cleaned up by context.buildUserContent after base64 encoding
// 确保临时文件在函数返回时被清理
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{
"file": file,
"error": err.Error(),
})
}
}
}()
if ev.Message != nil && len(ev.Message.Files) > 0 { if ev.Message != nil && len(ev.Message.Files) > 0 {
for _, file := range ev.Message.Files { for _, file := range ev.Message.Files {
@ -250,7 +237,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
if localPath == "" { if localPath == "" {
continue continue
} }
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, 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() {

View file

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@ -197,19 +196,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content := "" content := ""
mediaPaths := []string{} mediaPaths := []string{}
localFiles := []string{} // 跟踪需要清理的本地文件 // Note: Files will be cleaned up by context.buildUserContent after base64 encoding
// 确保临时文件在函数返回时被清理
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{
"file": file,
"error": err.Error(),
})
}
}
}()
if message.Text != "" { if message.Text != "" {
content += message.Text content += message.Text
@ -226,7 +213,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
photo := message.Photo[len(message.Photo)-1] photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID) photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" { if photoPath != "" {
localFiles = append(localFiles, photoPath)
mediaPaths = append(mediaPaths, photoPath) mediaPaths = append(mediaPaths, photoPath)
if content != "" { if content != "" {
content += "\n" content += "\n"
@ -238,7 +224,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if message.Voice != nil { if message.Voice != nil {
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
if voicePath != "" { if voicePath != "" {
localFiles = append(localFiles, voicePath)
mediaPaths = append(mediaPaths, voicePath) mediaPaths = append(mediaPaths, voicePath)
transcribedText := "" transcribedText := ""
@ -273,7 +258,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if message.Audio != nil { if message.Audio != nil {
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
if audioPath != "" { if audioPath != "" {
localFiles = append(localFiles, audioPath)
mediaPaths = append(mediaPaths, audioPath) mediaPaths = append(mediaPaths, audioPath)
if content != "" { if content != "" {
content += "\n" content += "\n"
@ -285,7 +269,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if message.Document != nil { if message.Document != nil {
docPath := c.downloadFile(ctx, message.Document.FileID, "") docPath := c.downloadFile(ctx, message.Document.FileID, "")
if docPath != "" { if docPath != "" {
localFiles = append(localFiles, docPath)
mediaPaths = append(mediaPaths, docPath) mediaPaths = append(mediaPaths, docPath)
if content != "" { if content != "" {
content += "\n" content += "\n"

View file

@ -71,11 +71,14 @@ func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string {
case "system": case "system":
// handled via --system-prompt flag // handled via --system-prompt flag
case "user": case "user":
parts = append(parts, "User: "+msg.Content) userText := ContentToString(msg.Content)
parts = append(parts, "User: "+userText)
case "assistant": case "assistant":
parts = append(parts, "Assistant: "+msg.Content) assistantText := ContentToString(msg.Content)
parts = append(parts, "Assistant: "+assistantText)
case "tool": case "tool":
parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) toolText := ContentToString(msg.Content)
parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, toolText))
} }
} }
@ -93,7 +96,8 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe
for _, msg := range messages { for _, msg := range messages {
if msg.Role == "system" { if msg.Role == "system" {
parts = append(parts, msg.Content) systemText := ContentToString(msg.Content)
parts = append(parts, systemText)
} }
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/option"
@ -56,6 +57,80 @@ func (p *ClaudeProvider) GetDefaultModel() string {
return "claude-sonnet-4-5-20250929" return "claude-sonnet-4-5-20250929"
} }
// buildContentBlocks converts Content (interface{}) to Claude content blocks.
// Handles both string content and multipart content (text + images).
func buildContentBlocks(content interface{}) []anthropic.ContentBlockParamUnion {
if content == nil {
return []anthropic.ContentBlockParamUnion{anthropic.NewTextBlock("")}
}
// Try string content first
if s, ok := content.(string); ok {
return []anthropic.ContentBlockParamUnion{anthropic.NewTextBlock(s)}
}
// Try multipart content ([]interface{})
if parts, ok := content.([]interface{}); ok {
var blocks []anthropic.ContentBlockParamUnion
for _, part := range parts {
partMap, ok := part.(map[string]interface{})
if !ok {
continue
}
partType, _ := partMap["type"].(string)
switch partType {
case "text":
if text, ok := partMap["text"].(string); ok {
blocks = append(blocks, anthropic.NewTextBlock(text))
}
case "image_url":
if imageURL, ok := partMap["image_url"].(map[string]interface{}); ok {
if url, ok := imageURL["url"].(string); ok {
// Parse data URL: data:image/jpeg;base64,<data>
if strings.HasPrefix(url, "data:") {
parts := strings.SplitN(url, ",", 2)
if len(parts) == 2 {
// Extract media type from data URL
mediaType := anthropic.Base64ImageSourceMediaTypeImageJPEG // default
if strings.Contains(parts[0], ";") {
mediaTypePart := strings.Split(parts[0], ";")[0]
if strings.HasPrefix(mediaTypePart, "data:") {
mimeType := mediaTypePart[5:]
switch mimeType {
case "image/png":
mediaType = anthropic.Base64ImageSourceMediaTypeImagePNG
case "image/gif":
mediaType = anthropic.Base64ImageSourceMediaTypeImageGIF
case "image/webp":
mediaType = anthropic.Base64ImageSourceMediaTypeImageWebP
default:
mediaType = anthropic.Base64ImageSourceMediaTypeImageJPEG
}
}
}
imageSource := anthropic.Base64ImageSourceParam{
Data: parts[1],
MediaType: mediaType,
}
blocks = append(blocks, anthropic.NewImageBlock(imageSource))
}
}
}
}
}
}
if len(blocks) > 0 {
return blocks
}
}
// Fallback to empty text block
return []anthropic.ContentBlockParamUnion{anthropic.NewTextBlock("")}
}
func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
var system []anthropic.TextBlockParam var system []anthropic.TextBlockParam
var anthropicMessages []anthropic.MessageParam var anthropicMessages []anthropic.MessageParam
@ -63,35 +138,43 @@ func buildClaudeParams(messages []Message, tools []ToolDefinition, model string,
for _, msg := range messages { for _, msg := range messages {
switch msg.Role { switch msg.Role {
case "system": case "system":
system = append(system, anthropic.TextBlockParam{Text: msg.Content}) systemText := ContentToString(msg.Content)
system = append(system, anthropic.TextBlockParam{Text: systemText})
case "user": case "user":
if msg.ToolCallID != "" { if msg.ToolCallID != "" {
// Tool result
resultText := ContentToString(msg.Content)
anthropicMessages = append(anthropicMessages, anthropicMessages = append(anthropicMessages,
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, resultText, false)),
) )
} else { } else {
// Regular user message (may include images)
blocks := buildContentBlocks(msg.Content)
anthropicMessages = append(anthropicMessages, anthropicMessages = append(anthropicMessages,
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), anthropic.NewUserMessage(blocks...),
) )
} }
case "assistant": case "assistant":
if len(msg.ToolCalls) > 0 { if len(msg.ToolCalls) > 0 {
var blocks []anthropic.ContentBlockParamUnion var blocks []anthropic.ContentBlockParamUnion
if msg.Content != "" { contentText := ContentToString(msg.Content)
blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) if contentText != "" {
blocks = append(blocks, anthropic.NewTextBlock(contentText))
} }
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
} }
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
} else { } else {
contentText := ContentToString(msg.Content)
anthropicMessages = append(anthropicMessages, anthropicMessages = append(anthropicMessages,
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), anthropic.NewAssistantMessage(anthropic.NewTextBlock(contentText)),
) )
} }
case "tool": case "tool":
resultText := ContentToString(msg.Content)
anthropicMessages = append(anthropicMessages, anthropicMessages = append(anthropicMessages,
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, resultText, false)),
) )
} }
} }

View file

@ -75,30 +75,33 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
for _, msg := range messages { for _, msg := range messages {
switch msg.Role { switch msg.Role {
case "system": case "system":
instructions = msg.Content instructions = ContentToString(msg.Content)
case "user": case "user":
if msg.ToolCallID != "" { if msg.ToolCallID != "" {
resultText := ContentToString(msg.Content)
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
CallID: msg.ToolCallID, CallID: msg.ToolCallID,
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(resultText)},
}, },
}) })
} else { } else {
userText := ContentToString(msg.Content)
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfMessage: &responses.EasyInputMessageParam{ OfMessage: &responses.EasyInputMessageParam{
Role: responses.EasyInputMessageRoleUser, Role: responses.EasyInputMessageRoleUser,
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(userText)},
}, },
}) })
} }
case "assistant": case "assistant":
if len(msg.ToolCalls) > 0 { if len(msg.ToolCalls) > 0 {
if msg.Content != "" { assistantText := ContentToString(msg.Content)
if assistantText != "" {
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfMessage: &responses.EasyInputMessageParam{ OfMessage: &responses.EasyInputMessageParam{
Role: responses.EasyInputMessageRoleAssistant, Role: responses.EasyInputMessageRoleAssistant,
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(assistantText)},
}, },
}) })
} }
@ -113,18 +116,20 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
}) })
} }
} else { } else {
assistantText := ContentToString(msg.Content)
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfMessage: &responses.EasyInputMessageParam{ OfMessage: &responses.EasyInputMessageParam{
Role: responses.EasyInputMessageRoleAssistant, Role: responses.EasyInputMessageRoleAssistant,
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(assistantText)},
}, },
}) })
} }
case "tool": case "tool":
resultText := ContentToString(msg.Content)
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
CallID: msg.ToolCallID, CallID: msg.ToolCallID,
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(resultText)},
}, },
}) })
} }

View file

@ -59,7 +59,7 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
for _, msg := range messages { for _, msg := range messages {
out = append(out, tempMessage{ out = append(out, tempMessage{
Role: msg.Role, Role: msg.Role,
Content: msg.Content, Content: ContentToString(msg.Content),
}) })
} }

View file

@ -1,6 +1,9 @@
package providers package providers
import "context" import (
"context"
"strings"
)
type ToolCall struct { type ToolCall struct {
ID string `json:"id"` ID string `json:"id"`
@ -29,10 +32,10 @@ type UsageInfo struct {
} }
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content interface{} `json:"content"` // Can be string or []interface{} for multipart content (text + images)
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"`
} }
type LLMProvider interface { type LLMProvider interface {
@ -50,3 +53,32 @@ type ToolFunctionDefinition struct {
Description string `json:"description"` Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"` Parameters map[string]interface{} `json:"parameters"`
} }
// ContentToString extracts text from Content (interface{}).
// Handles both string content and multipart []interface{} content (text + images).
// For multipart content, extracts only the text parts and joins them with newlines.
func ContentToString(content interface{}) string {
if content == nil {
return ""
}
if s, ok := content.(string); ok {
return s
}
// If content is multipart ([]interface{}), extract text parts
if parts, ok := content.([]interface{}); ok {
var texts []string
for _, part := range parts {
partMap, ok := part.(map[string]interface{})
if !ok {
continue
}
if partType, _ := partMap["type"].(string); partType == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
}
}
}
return strings.Join(texts, "\n")
}
return ""
}

View file

@ -2,6 +2,7 @@ package utils
import ( import (
"io" "io"
"mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -12,6 +13,44 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
// GetMimeType returns the MIME type for a file based on its extension.
// If the MIME type cannot be determined, returns "application/octet-stream".
func GetMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return "application/octet-stream"
}
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
return "application/octet-stream"
}
// Remove charset parameter if present (e.g., "text/html; charset=utf-8" -> "text/html")
if idx := strings.Index(mimeType, ";"); idx > 0 {
mimeType = mimeType[:idx]
}
return mimeType
}
// IsImageFile checks if a file is an image file based on its filename extension and content type.
func IsImageFile(filename, contentType string) bool {
imageExtensions := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
imageTypes := []string{"image/"}
for _, ext := range imageExtensions {
if strings.HasSuffix(strings.ToLower(filename), ext) {
return true
}
}
for _, imageType := range imageTypes {
if strings.HasPrefix(strings.ToLower(contentType), imageType) {
return true
}
}
return false
}
// 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"}