Files
ai-agent/internal/services/conversation_vision_asset.go
T
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

108 lines
3.6 KiB
Go

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
}