package services import ( "encoding/base64" "fmt" "io" "net/http" "strings" "code.tczkiot.com/wlw/ai-agent/internal/models" "code.tczkiot.com/wlw/ai-agent/internal/pkg/enums" ) const maxConversationVisionImageBytes int64 = 5 << 20 // ConversationVisionImage contains only server-resolved, inline image data. // It never trusts or forwards the URL/provider/storage key from message JSON. type ConversationVisionImage struct { AssetID string Filename string MIMEType string Base64Data string FileSize int64 } // LoadConversationVisionImages resolves explicitly supplied customer image // messages. Callers must pass only the current message (or a future explicitly // authorized quote); this service never queries conversation history itself. // Every asset is checked against the conversation before private storage is // opened. Invalid, deleted, oversized, or malformed images are skipped so a // text-only model reply can still proceed. func (s *assetService) LoadConversationVisionImages(conversationID int64, messages []models.Message, limit int) []ConversationVisionImage { if conversationID <= 0 || limit <= 0 { return nil } if limit > 9 { limit = 9 } images := make([]ConversationVisionImage, 0, limit) seenAssets := make(map[string]struct{}, limit) for _, message := range messages { if message.ConversationID != conversationID || message.SenderType != enums.IMSenderTypeCustomer || message.MessageType != enums.IMMessageTypeImage || message.RecalledAt != nil || message.SendStatus == enums.IMMessageStatusRecalled { continue } messageImages := s.loadConversationVisionImagesFromMessage(conversationID, message) for _, image := range messageImages { if _, exists := seenAssets[image.AssetID]; exists { continue } seenAssets[image.AssetID] = struct{}{} images = append(images, image) } } if len(images) > limit { images = images[len(images)-limit:] } return images } func (s *assetService) loadConversationVisionImagesFromMessage(conversationID int64, message models.Message) []ConversationVisionImage { payload, err := parseIMMessageAssetPayload(message.Payload) if err != nil { return nil } images := make([]ConversationVisionImage, 0, len(payload.items())) for _, item := range payload.items() { image, err := s.loadConversationVisionAsset(conversationID, item.AssetID) if err == nil && image != nil { images = append(images, *image) } } return images } func (s *assetService) loadConversationVisionAsset(conversationID int64, assetID string) (*ConversationVisionImage, error) { asset := s.GetByAssetID(assetID) if err := validateConversationAsset(asset, conversationID, enums.IMMessageTypeImage); err != nil { return nil, err } if asset.FileSize <= 0 || asset.FileSize > maxConversationVisionImageBytes { return nil, fmt.Errorf("conversation image size is outside the model input limit") } reader, err := s.OpenReader(asset) if err != nil { return nil, err } defer func() { _ = reader.Close() }() data, err := io.ReadAll(io.LimitReader(reader, maxConversationVisionImageBytes+1)) if err != nil { return nil, err } if int64(len(data)) > maxConversationVisionImageBytes { return nil, fmt.Errorf("conversation image exceeds the model input limit") } mimeType := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]) if !isSupportedVisionImageMIME(mimeType) { return nil, fmt.Errorf("conversation asset is not a supported image") } return &ConversationVisionImage{ AssetID: asset.AssetID, Filename: strings.TrimSpace(asset.Filename), MIMEType: mimeType, Base64Data: base64.StdEncoding.EncodeToString(data), FileSize: int64(len(data)), }, nil }