18c9354095
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
108 lines
3.6 KiB
Go
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
|
|
}
|