yao/agent/content/image/image.go
Max 54607e26b7 feat(agent): enhance image content handling and vision support
- Added support for processing image content in the agent, providing placeholder text when image parsing fails.
- Implemented a fallback mechanism to utilize vision tools for image analysis, improving the handling of image content.
- Updated tests to validate the new image handling behavior, ensuring correct responses when vision support is unavailable.
- Enhanced system prompts to guide users on using the new `image_read` tool for image analysis, improving user experience.
2026-05-04 13:46:30 +08:00

447 lines
14 KiB
Go

package image
import (
"encoding/base64"
"fmt"
"strings"
"github.com/yaoapp/yao/agent/content/tools"
"github.com/yaoapp/yao/agent/content/types"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message"
searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/attachment"
"github.com/yaoapp/yao/tools/vision"
)
// Image handles image content
type Image struct {
options *types.Options
}
// New creates a new image handler
func New(options *types.Options) *Image {
return &Image{options: options}
}
// Parse parses image content
// Logic:
// 1. Check model capabilities first
// 2. If forceUses is true and uses.Vision is specified -> use vision tool regardless of model capability
// 3. If model supports vision -> pass through or convert to base64 format
// 4. If model doesn't support vision -> use vision agent/MCP to extract text
func (h *Image) Parse(ctx *agentContext.Context, content agentContext.ContentPart) (agentContext.ContentPart, []*searchTypes.Reference, error) {
if content.ImageURL == nil || content.ImageURL.URL == "" {
return content, nil, fmt.Errorf("image_url content missing URL")
}
// Check model capabilities first
supportsVision, visionFormat := agentContext.GetVisionSupport(h.options.Capabilities)
// Check if we should force using Uses tools
forceUses := h.options.CompletionOptions != nil && h.options.CompletionOptions.ForceUses
// If forceUses is true and uses.Vision is specified, use vision tool regardless of model capability
if forceUses && h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil && h.options.CompletionOptions.Uses.Vision != "" {
// Check cache first before calling agent
cachedText, found, err := h.readFromCache(ctx, content.ImageURL.URL)
if err == nil && found {
return agentContext.ContentPart{
Type: agentContext.ContentText,
Text: cachedText,
}, nil, nil
}
return h.agent(ctx, content)
}
// If model supports vision
if supportsVision {
url := content.ImageURL.URL
// If it's already a data URI (base64), pass through directly
if strings.HasPrefix(url, "data:") {
return content, nil, nil
}
// Convert to base64 format
return h.base64(ctx, content, visionFormat)
}
// Model doesn't support vision - fallback chain:
// 1. Cache -> 2. Uses.Vision (explicit config) -> 3. tools/vision (auto) -> 4. Placeholder text
cachedText, found, err := h.readFromCache(ctx, content.ImageURL.URL)
if err == nil && found {
return agentContext.ContentPart{
Type: agentContext.ContentText,
Text: cachedText,
}, nil, nil
}
if h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil && h.options.CompletionOptions.Uses.Vision != "" {
return h.agent(ctx, content)
}
if text, err := h.readImageWithTools(ctx, content); err == nil {
h.saveToCache(ctx, content.ImageURL.URL, text)
return agentContext.ContentPart{Type: agentContext.ContentText, Text: text}, nil, nil
}
return agentContext.ContentPart{Type: agentContext.ContentText, Text: "[Image content - vision model not available]"}, nil, nil
}
// base64 encodes image content to base64 (for vision support)
func (h *Image) base64(ctx *agentContext.Context, content agentContext.ContentPart, format agentContext.VisionFormat) (agentContext.ContentPart, []*searchTypes.Reference, error) {
if content.ImageURL == nil || content.ImageURL.URL == "" {
return content, nil, fmt.Errorf("image_url content missing URL")
}
url := content.ImageURL.URL
// Read image data from source
data, contentType, err := h.read(ctx, url)
if err != nil {
return content, nil, fmt.Errorf("failed to read image: %w", err)
}
// Encode to base64 data URI
base64Data := EncodeToBase64DataURI(data, contentType)
// Return as image_url ContentPart
return agentContext.ContentPart{
Type: agentContext.ContentImageURL,
ImageURL: &agentContext.ImageURL{
URL: base64Data,
Detail: content.ImageURL.Detail,
},
}, nil, nil
}
// read reads image content from various sources
func (h *Image) read(ctx *agentContext.Context, url string) ([]byte, string, error) {
// Determine source type and read accordingly
if strings.HasPrefix(url, "data:") {
// Data URI format: data:image/png;base64,xxxxx
return h.readFromDataURI(url)
}
if strings.HasPrefix(url, "__") {
// Uploader wrapper format: __uploader://fileid
return h.readFromUploader(ctx, url)
}
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
// HTTP URL - for now return error, can be implemented later
return nil, "", fmt.Errorf("HTTP URL fetch not implemented yet: %s", url)
}
// Unknown source
return nil, "", fmt.Errorf("unsupported image source: %s", url)
}
// readFromDataURI reads image content from a data URI
func (h *Image) readFromDataURI(dataURI string) ([]byte, string, error) {
// Parse data URI: data:image/png;base64,xxxxx
if !strings.HasPrefix(dataURI, "data:") {
return nil, "", fmt.Errorf("invalid data URI format")
}
// Find the comma separator
commaIndex := strings.Index(dataURI, ",")
if commaIndex == -1 {
return nil, "", fmt.Errorf("invalid data URI: missing comma separator")
}
// Extract metadata part (e.g., "image/png;base64")
metadata := dataURI[5:commaIndex] // Skip "data:"
base64Data := dataURI[commaIndex+1:]
// Parse content type
contentType := "image/png" // default
if strings.Contains(metadata, ";") {
parts := strings.Split(metadata, ";")
if len(parts) > 0 && parts[0] != "" {
contentType = parts[0]
}
} else if metadata != "" && metadata != "base64" {
contentType = metadata
}
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, "", fmt.Errorf("failed to decode base64 data: %w", err)
}
return data, contentType, nil
}
// readFromUploader reads image content from file uploader __uploader://fileid
func (h *Image) readFromUploader(ctx *agentContext.Context, wrapper string) ([]byte, string, error) {
// Parse wrapper to get uploader name and file ID
uploaderName, fileID, ok := attachment.Parse(wrapper)
if !ok {
return nil, "", fmt.Errorf("invalid uploader wrapper format: %s", wrapper)
}
// Get attachment manager
manager, exists := attachment.Managers[uploaderName]
if !exists {
return nil, "", fmt.Errorf("uploader '%s' not found", uploaderName)
}
// Get file info
file, err := manager.Info(ctx.Context, fileID)
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
// Read file content
data, err := manager.Read(ctx.Context, fileID)
if err != nil {
return nil, "", fmt.Errorf("failed to read file: %w", err)
}
return data, file.ContentType, nil
}
// readFromCache reads cached text content for an image
func (h *Image) readFromCache(ctx *agentContext.Context, url string) (string, bool, error) {
// Parse URL to check if it's an uploader wrapper
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
return "", false, nil // Not an uploader wrapper, no cache
}
// Try attachment manager's content_preview (cross-call cache)
manager, exists := attachment.Managers[uploaderName]
if !exists {
return "", false, nil
}
// GetText with fullContent=false to get preview (default)
text, err := manager.GetText(ctx.Context, fileID, false)
if err == nil && text != "" {
return text, true, nil
}
// No cache found
return "", false, nil
}
// saveToCache saves processed text to cache
func (h *Image) saveToCache(ctx *agentContext.Context, url string, text string) error {
// Parse URL to get uploader name and file ID
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
return nil // Not an uploader wrapper, nothing to cache
}
// Save to attachment manager for future calls
manager, exists := attachment.Managers[uploaderName]
if !exists {
return nil
}
return manager.SaveText(ctx.Context, fileID, text)
}
// agent calls image agent to parse image content
// Note: Cache check is done in Parse() before calling this method
func (h *Image) agent(ctx *agentContext.Context, content agentContext.ContentPart) (agentContext.ContentPart, []*searchTypes.Reference, error) {
if content.ImageURL == nil || content.ImageURL.URL == "" {
return content, nil, fmt.Errorf("image_url content missing URL")
}
url := content.ImageURL.URL
// Get vision tool from options
visionTool := ""
if h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil {
visionTool = h.options.CompletionOptions.Uses.Vision
}
if visionTool == "" {
return content, nil, fmt.Errorf("no vision tool specified in uses.Vision")
}
// Parse vision tool format
// Format can be:
// - "agent_id" (call agent)
// - "mcp:server_id" (call MCP tool)
var text string
var err error
if strings.HasPrefix(visionTool, "mcp:") {
// MCP tool
serverID := strings.TrimPrefix(visionTool, "mcp:")
text, err = h.callMCPVisionTool(ctx, serverID, content)
} else {
// Agent call
text, err = h.callVisionAgent(ctx, visionTool, content)
}
if err != nil {
return content, nil, fmt.Errorf("failed to process image with vision tool: %w", err)
}
// Cache the result
if cacheErr := h.saveToCache(ctx, url, text); cacheErr != nil {
// Log error but don't fail the request
fmt.Printf("Warning: failed to cache processed text: %v\n", cacheErr)
}
// Return as text content
return agentContext.ContentPart{
Type: agentContext.ContentText,
Text: text,
}, nil, nil
}
// callVisionAgent calls a vision agent to describe the image
func (h *Image) callVisionAgent(ctx *agentContext.Context, agentID string, content agentContext.ContentPart) (string, error) {
// Read image data and convert to base64
data, contentType, err := h.read(ctx, content.ImageURL.URL)
if err != nil {
return "", fmt.Errorf("failed to read image: %w", err)
}
base64Data := EncodeToBase64DataURI(data, contentType)
// Prepare message with image
message := agentContext.Message{
Role: agentContext.RoleUser,
Content: []agentContext.ContentPart{
{
Type: agentContext.ContentText,
Text: "Please analyze this image.",
},
{
Type: agentContext.ContentImageURL,
ImageURL: &agentContext.ImageURL{
URL: base64Data,
Detail: agentContext.DetailAuto,
},
},
},
}
// Send loading message
loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing"))
// Call agent using the tools package
result, err := tools.CallAgent(ctx, agentID, message)
// Send done message
h.sendLoadingDone(ctx, loadingID)
return result, err
}
// callMCPVisionTool calls an MCP vision tool to describe the image
func (h *Image) callMCPVisionTool(ctx *agentContext.Context, serverID string, content agentContext.ContentPart) (string, error) {
// Read image data and convert to base64
data, contentType, err := h.read(ctx, content.ImageURL.URL)
if err != nil {
return "", fmt.Errorf("failed to read image: %w", err)
}
base64Data := EncodeToBase64DataURI(data, contentType)
// Prepare arguments for MCP tool
arguments := map[string]interface{}{
"image": base64Data,
"content_type": contentType,
}
// Send loading message
loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing"))
// Call MCP tool (typically "describe_image" or similar)
result, err := tools.CallMCPTool(ctx, serverID, "describe_image", arguments)
// Send done message
h.sendLoadingDone(ctx, loadingID)
return result, err
}
// readImageWithTools calls tools/vision.ReadImage to convert image to text
// using a vision-capable model resolved via llmprovider.
func (h *Image) readImageWithTools(ctx *agentContext.Context, content agentContext.ContentPart) (string, error) {
if ctx.Authorized == nil {
return "", fmt.Errorf("no auth info available for vision model resolution")
}
src := wrapperToAttachURI(content.ImageURL.URL)
loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing"))
resp, err := vision.ReadImage(ctx.Context, src, "Please describe this image in detail.", 1080, ctx.Authorized)
h.sendLoadingDone(ctx, loadingID)
if err != nil {
return "", err
}
return resp.Content, nil
}
// wrapperToAttachURI converts __uploader://fileID to attach://uploader/fileID
// format expected by tools/vision.readBytes.
func wrapperToAttachURI(url string) string {
uploaderName, fileID, ok := attachment.Parse(url)
if !ok {
return url
}
return "attach://" + uploaderName + "/" + fileID
}
// sendLoading sends a loading message and returns the message ID
// Returns empty string if SilentLoading is enabled
func (h *Image) sendLoading(ctx *agentContext.Context, msg string) string {
// Skip loading message if SilentLoading is enabled (called from parent handler like PDF)
if h.options != nil && h.options.SilentLoading {
return ""
}
loadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": msg,
},
}
msgID, err := ctx.SendStream(loadingMsg)
if err != nil {
return ""
}
return msgID
}
// sendLoadingDone marks the loading message as done
func (h *Image) sendLoadingDone(ctx *agentContext.Context, loadingID string) {
if loadingID == "" {
return
}
doneMsg := &message.Message{
MessageID: loadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"done": true,
},
}
ctx.Send(doneMsg)
}
// EncodeToBase64DataURI encodes data to base64 with data URI prefix
func EncodeToBase64DataURI(data []byte, contentType string) string {
if contentType == "" {
contentType = "image/png" // default for images
}
encoded := base64.StdEncoding.EncodeToString(data)
return fmt.Sprintf("data:%s;base64,%s", contentType, encoded)
}