|
@@ -1,13 +1,18 @@
|
|
|
package service
|
|
package service
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
|
|
+ "bytes"
|
|
|
"context"
|
|
"context"
|
|
|
|
|
+ "encoding/base64"
|
|
|
"encoding/json"
|
|
"encoding/json"
|
|
|
"errors"
|
|
"errors"
|
|
|
"fmt"
|
|
"fmt"
|
|
|
"log"
|
|
"log"
|
|
|
"strings"
|
|
"strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
|
|
|
|
|
+ "github.com/2930134478/AI-CS/backend/infra"
|
|
|
|
|
+ "github.com/2930134478/AI-CS/backend/infra/search"
|
|
|
"github.com/2930134478/AI-CS/backend/models"
|
|
"github.com/2930134478/AI-CS/backend/models"
|
|
|
"github.com/2930134478/AI-CS/backend/repository"
|
|
"github.com/2930134478/AI-CS/backend/repository"
|
|
|
"github.com/2930134478/AI-CS/backend/service/rag"
|
|
"github.com/2930134478/AI-CS/backend/service/rag"
|
|
@@ -17,102 +22,181 @@ import (
|
|
|
|
|
|
|
|
// AIService AI 服务(负责调用 AI 生成回复)
|
|
// AIService AI 服务(负责调用 AI 生成回复)
|
|
|
type AIService struct {
|
|
type AIService struct {
|
|
|
- aiConfigRepo *repository.AIConfigRepository
|
|
|
|
|
- messageRepo *repository.MessageRepository
|
|
|
|
|
- conversationRepo *repository.ConversationRepository
|
|
|
|
|
- retrievalService *rag.RetrievalService // RAG 检索服务
|
|
|
|
|
- providerFactory *AIProviderFactory
|
|
|
|
|
|
|
+ aiConfigRepo *repository.AIConfigRepository
|
|
|
|
|
+ messageRepo *repository.MessageRepository
|
|
|
|
|
+ conversationRepo *repository.ConversationRepository
|
|
|
|
|
+ retrievalService *rag.RetrievalService
|
|
|
|
|
+ providerFactory *AIProviderFactory
|
|
|
|
|
+ webSearchProvider search.WebSearchProvider // 可选,自建联网时用
|
|
|
|
|
+ embeddingConfigSvc *EmbeddingConfigService // 读取联网方式:厂商内置 / 自建
|
|
|
|
|
+ promptConfigSvc *PromptConfigService // 可选,提示词配置(为空则用代码内默认)
|
|
|
|
|
+ storageService infra.StorageService // 可选,用于多模态识图时读取消息附件
|
|
|
|
|
+ systemLogSvc *SystemLogService // 可选,结构化日志服务
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// NewAIService 创建 AI 服务实例。
|
|
|
|
|
|
|
+// NewAIService 创建 AI 服务实例。webSearchProvider、storageService 可为 nil。
|
|
|
func NewAIService(
|
|
func NewAIService(
|
|
|
aiConfigRepo *repository.AIConfigRepository,
|
|
aiConfigRepo *repository.AIConfigRepository,
|
|
|
messageRepo *repository.MessageRepository,
|
|
messageRepo *repository.MessageRepository,
|
|
|
conversationRepo *repository.ConversationRepository,
|
|
conversationRepo *repository.ConversationRepository,
|
|
|
- retrievalService *rag.RetrievalService, // 添加 RAG 检索服务
|
|
|
|
|
|
|
+ retrievalService *rag.RetrievalService,
|
|
|
|
|
+ webSearchProvider search.WebSearchProvider,
|
|
|
|
|
+ embeddingConfigSvc *EmbeddingConfigService,
|
|
|
|
|
+ promptConfigSvc *PromptConfigService,
|
|
|
|
|
+ storageService infra.StorageService,
|
|
|
|
|
+ systemLogSvc *SystemLogService,
|
|
|
) *AIService {
|
|
) *AIService {
|
|
|
return &AIService{
|
|
return &AIService{
|
|
|
- aiConfigRepo: aiConfigRepo,
|
|
|
|
|
- messageRepo: messageRepo,
|
|
|
|
|
- conversationRepo: conversationRepo,
|
|
|
|
|
- retrievalService: retrievalService,
|
|
|
|
|
- providerFactory: NewAIProviderFactory(),
|
|
|
|
|
|
|
+ aiConfigRepo: aiConfigRepo,
|
|
|
|
|
+ messageRepo: messageRepo,
|
|
|
|
|
+ conversationRepo: conversationRepo,
|
|
|
|
|
+ retrievalService: retrievalService,
|
|
|
|
|
+ providerFactory: NewAIProviderFactory(),
|
|
|
|
|
+ webSearchProvider: webSearchProvider,
|
|
|
|
|
+ embeddingConfigSvc: embeddingConfigSvc,
|
|
|
|
|
+ promptConfigSvc: promptConfigSvc,
|
|
|
|
|
+ storageService: storageService,
|
|
|
|
|
+ systemLogSvc: systemLogSvc,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// GenerateAIResponse 为对话生成 AI 回复。
|
|
|
|
|
-// conversationID: 对话ID
|
|
|
|
|
-// userMessage: 用户消息
|
|
|
|
|
-// userID: 用户ID(用于回退查找 AI 配置)
|
|
|
|
|
-// 返回: AI 回复内容,如果失败返回错误
|
|
|
|
|
|
|
+// GenerateAIResponse 为对话生成 AI 回复(兼容旧调用,使用默认数据源选项)。
|
|
|
|
|
+// 返回: AI 回复内容,若失败返回错误。
|
|
|
func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, userID uint) (string, error) {
|
|
func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, userID uint) (string, error) {
|
|
|
- // 1. 获取对话信息,优先使用对话绑定的 AI 配置
|
|
|
|
|
|
|
+ res, err := s.GenerateAIResponseWithOptions(conversationID, userMessage, userID, nil)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "", err
|
|
|
|
|
+ }
|
|
|
|
|
+ return res.Content, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GenerateAIResponseWithOptions 根据数据源开关生成一条合成回复,并返回使用的来源标记。
|
|
|
|
|
+// opts 为 nil 时使用默认:知识库+大模型开,联网关。
|
|
|
|
|
+func (s *AIService) GenerateAIResponseWithOptions(conversationID uint, userMessage string, userID uint, opts *GenerateAIResponseInput) (*GenerateAIResponseResult, error) {
|
|
|
|
|
+ useKB := true
|
|
|
|
|
+ useLLM := true
|
|
|
|
|
+ useWeb := false
|
|
|
|
|
+ needWeb := false
|
|
|
|
|
+ if opts != nil {
|
|
|
|
|
+ if opts.UseKnowledgeBase != nil {
|
|
|
|
|
+ useKB = *opts.UseKnowledgeBase
|
|
|
|
|
+ }
|
|
|
|
|
+ if opts.UseLLM != nil {
|
|
|
|
|
+ useLLM = *opts.UseLLM
|
|
|
|
|
+ }
|
|
|
|
|
+ if opts.UseWebSearch != nil {
|
|
|
|
|
+ useWeb = *opts.UseWebSearch
|
|
|
|
|
+ }
|
|
|
|
|
+ needWeb = opts.NeedWebSearch
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
conversation, err := s.conversationRepo.GetByID(conversationID)
|
|
conversation, err := s.conversationRepo.GetByID(conversationID)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- return "", fmt.Errorf("获取对话失败: %v", err)
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("获取对话失败: %v", err)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 以下 config 为「AI 配置」:对话/联网均使用此接口;与「知识库向量配置」(embedding,如 nekoai)无关。
|
|
|
var config *models.AIConfig
|
|
var config *models.AIConfig
|
|
|
if conversation.AIConfigID != nil {
|
|
if conversation.AIConfigID != nil {
|
|
|
- // 使用对话绑定的配置(多厂商支持)
|
|
|
|
|
config, err = s.aiConfigRepo.GetByID(*conversation.AIConfigID)
|
|
config, err = s.aiConfigRepo.GetByID(*conversation.AIConfigID)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- return "", fmt.Errorf("获取 AI 配置失败: %v", err)
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
|
|
|
}
|
|
}
|
|
|
- // 验证配置是否启用
|
|
|
|
|
if !config.IsActive {
|
|
if !config.IsActive {
|
|
|
- return "", errors.New("该模型配置已禁用")
|
|
|
|
|
|
|
+ return nil, errors.New("该模型配置已禁用")
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
- // 回退:使用用户默认配置(向后兼容)
|
|
|
|
|
config, err = s.aiConfigRepo.GetActiveByUserID(userID, "text")
|
|
config, err = s.aiConfigRepo.GetActiveByUserID(userID, "text")
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
- return "", errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
|
|
|
|
|
|
|
+ return nil, errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
|
|
|
}
|
|
}
|
|
|
- return "", fmt.Errorf("获取 AI 配置失败: %v", err)
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 2. 解密 API Key
|
|
|
|
|
apiKey, err := utils.DecryptAPIKey(config.APIKey)
|
|
apiKey, err := utils.DecryptAPIKey(config.APIKey)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- return "", fmt.Errorf("解密 API Key 失败: %v", err)
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("解密 API Key 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 若当前 AI 配置为生图模型(model_type=image),则直接走生图逻辑,
|
|
|
|
|
+ // 不参与 RAG/联网与文本对话流程。前端仍显示在「AI 客服」渠道下。
|
|
|
|
|
+ if config.ModelType == "image" {
|
|
|
|
|
+ log.Printf("[生图] 对话ID=%d 使用 model_type=image 配置 id=%d,走 GenerateImageReply", conversationID, config.ID)
|
|
|
|
|
+ return s.GenerateImageReply(conversationID, userMessage, userID)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 调试:确认本条对话实际使用的 AI 配置(便于排查联网/厂商内置是否走对接口)
|
|
|
|
|
+ if needWeb || useWeb {
|
|
|
|
|
+ convAIConfigID := "nil"
|
|
|
|
|
+ if conversation.AIConfigID != nil {
|
|
|
|
|
+ convAIConfigID = fmt.Sprintf("%d", *conversation.AIConfigID)
|
|
|
|
|
+ }
|
|
|
|
|
+ apiURLMask := config.APIURL
|
|
|
|
|
+ if len(apiURLMask) > 50 {
|
|
|
|
|
+ apiURLMask = apiURLMask[:50] + "..."
|
|
|
|
|
+ }
|
|
|
|
|
+ log.Printf("[联网] 对话ID=%d 使用的AI配置: conversation.ai_config_id=%s, config.id=%d, provider=%s, api_url=%s",
|
|
|
|
|
+ conversationID, convAIConfigID, config.ID, config.Provider, apiURLMask)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 3. 获取对话历史(用于上下文)
|
|
|
|
|
history, err := s.buildConversationHistory(conversationID)
|
|
history, err := s.buildConversationHistory(conversationID)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
log.Printf("⚠️ 获取对话历史失败: %v", err)
|
|
log.Printf("⚠️ 获取对话历史失败: %v", err)
|
|
|
- // 即使获取历史失败,也继续处理(使用空历史)
|
|
|
|
|
history = []MessageHistory{}
|
|
history = []MessageHistory{}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 4. RAG 检索:从知识库中检索相关文档
|
|
|
|
|
- ragContext := ""
|
|
|
|
|
- if s.retrievalService != nil {
|
|
|
|
|
- ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation)
|
|
|
|
|
|
|
+ // 多模态识图:当前条带图时读取文件并转 base64 供 provider 使用
|
|
|
|
|
+ var imageBase64, imageMimeType string
|
|
|
|
|
+ if opts != nil && opts.Attachment != nil && opts.Attachment.FileType == "image" && opts.Attachment.FileURL != "" && s.storageService != nil {
|
|
|
|
|
+ data, err := s.storageService.ReadMessageFile(opts.Attachment.FileURL)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- log.Printf("⚠️ RAG 检索失败: %v,继续使用无知识库上下文", err)
|
|
|
|
|
- // RAG 检索失败不影响主流程,继续处理
|
|
|
|
|
|
|
+ log.Printf("⚠️ 读取消息图片失败: %v", err)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ imageBase64 = base64.StdEncoding.EncodeToString(data)
|
|
|
|
|
+ imageMimeType = opts.Attachment.MimeType
|
|
|
|
|
+ if imageMimeType == "" {
|
|
|
|
|
+ imageMimeType = "image/jpeg"
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 5. 构建增强的用户消息(包含 RAG 上下文)
|
|
|
|
|
- enhancedUserMessage := userMessage
|
|
|
|
|
- if ragContext != "" {
|
|
|
|
|
- enhancedUserMessage = s.buildRAGPrompt(userMessage, ragContext)
|
|
|
|
|
|
|
+ var ragContext string
|
|
|
|
|
+ ragStartedAt := time.Now()
|
|
|
|
|
+ if useKB && s.retrievalService != nil {
|
|
|
|
|
+ ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ log.Printf("⚠️ RAG 检索失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.systemLogSvc != nil {
|
|
|
|
|
+ hit := strings.TrimSpace(ragContext) != ""
|
|
|
|
|
+ convID := conversationID
|
|
|
|
|
+ uID := userID
|
|
|
|
|
+ _ = s.systemLogSvc.Create(CreateSystemLogInput{
|
|
|
|
|
+ Level: "info",
|
|
|
|
|
+ Category: "rag",
|
|
|
|
|
+ Event: "rag_context_result",
|
|
|
|
|
+ Source: "backend",
|
|
|
|
|
+ ConversationID: &convID,
|
|
|
|
|
+ UserID: &uID,
|
|
|
|
|
+ Message: "RAG 检索完成",
|
|
|
|
|
+ Meta: map[string]interface{}{
|
|
|
|
|
+ "hit": hit,
|
|
|
|
|
+ "context_len": len(ragContext),
|
|
|
|
|
+ "elapsed_ms": time.Since(ragStartedAt).Milliseconds(),
|
|
|
|
|
+ "use_kb": useKB,
|
|
|
|
|
+ "need_web": needWeb,
|
|
|
|
|
+ "use_web": useWeb,
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 6. 解析适配器配置(如果有)
|
|
|
|
|
var adapterConfig *AdapterConfig
|
|
var adapterConfig *AdapterConfig
|
|
|
if config.AdapterConfig != "" {
|
|
if config.AdapterConfig != "" {
|
|
|
- if err := json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig); err != nil {
|
|
|
|
|
- log.Printf("⚠️ 解析适配器配置失败: %v,使用默认配置", err)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ _ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // 7. 创建 AI 提供商
|
|
|
|
|
aiConfig := AIConfig{
|
|
aiConfig := AIConfig{
|
|
|
APIURL: config.APIURL,
|
|
APIURL: config.APIURL,
|
|
|
APIKey: apiKey,
|
|
APIKey: apiKey,
|
|
@@ -121,21 +205,330 @@ func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string,
|
|
|
Provider: config.Provider,
|
|
Provider: config.Provider,
|
|
|
AdapterConfig: adapterConfig,
|
|
AdapterConfig: adapterConfig,
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
provider, err := s.providerFactory.CreateProvider(aiConfig)
|
|
provider, err := s.providerFactory.CreateProvider(aiConfig)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- return "", fmt.Errorf("创建 AI 提供商失败: %v", err)
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("创建 AI 提供商失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var sources []string
|
|
|
|
|
+ enhancedMessage := userMessage
|
|
|
|
|
+
|
|
|
|
|
+ // 1) 有知识库匹配:以知识库为主生成;若本回合允许联网,则用增强 prompt + 联网工具,由模型在无关/不足时用自身知识或联网
|
|
|
|
|
+ if ragContext != "" {
|
|
|
|
|
+ sources = append(sources, "knowledge_base")
|
|
|
|
|
+ if needWeb && useWeb {
|
|
|
|
|
+ webSource := "custom"
|
|
|
|
|
+ if s.embeddingConfigSvc != nil {
|
|
|
|
|
+ webSource, _ = s.embeddingConfigSvc.GetWebSearchSource()
|
|
|
|
|
+ }
|
|
|
|
|
+ enhancedMessage = s.buildRAGPromptWithWebOptional(userMessage, ragContext)
|
|
|
|
|
+ content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, enhancedMessage, webSource, imageBase64, imageMimeType)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ log.Printf("⚠️ RAG+联网(function calling)失败: %v,回退到仅 RAG", err)
|
|
|
|
|
+ if s.systemLogSvc != nil {
|
|
|
|
|
+ _ = s.systemLogSvc.Create(CreateSystemLogInput{
|
|
|
|
|
+ Level: "warn",
|
|
|
|
|
+ Category: "ai",
|
|
|
|
|
+ Event: "rag_web_fallback",
|
|
|
|
|
+ Source: "backend",
|
|
|
|
|
+ ConversationID: &conversationID,
|
|
|
|
|
+ UserID: &userID,
|
|
|
|
|
+ Message: "RAG+联网失败,回退到仅RAG",
|
|
|
|
|
+ Meta: map[string]interface{}{
|
|
|
|
|
+ "error": err.Error(),
|
|
|
|
|
+ "web_source": webSource,
|
|
|
|
|
+ "ai_config": config.ID,
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) {
|
|
|
|
|
+ log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。")
|
|
|
|
|
+ }
|
|
|
|
|
+ enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
|
|
|
|
|
+ } else if content != "" {
|
|
|
|
|
+ sources = append(sources, "llm")
|
|
|
|
|
+ if usedWeb {
|
|
|
|
|
+ sources = append(sources, "web")
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.systemLogSvc != nil {
|
|
|
|
|
+ convID := conversationID
|
|
|
|
|
+ uID := userID
|
|
|
|
|
+ _ = s.systemLogSvc.Create(CreateSystemLogInput{
|
|
|
|
|
+ Level: "info",
|
|
|
|
|
+ Category: "ai",
|
|
|
|
|
+ Event: "ai_web_success",
|
|
|
|
|
+ Source: "backend",
|
|
|
|
|
+ ConversationID: &convID,
|
|
|
|
|
+ UserID: &uID,
|
|
|
|
|
+ Message: "RAG+联网生成成功",
|
|
|
|
|
+ Meta: map[string]interface{}{
|
|
|
|
|
+ "sources": strings.Join(sources, ","),
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return &GenerateAIResponseResult{
|
|
|
|
|
+ Content: content,
|
|
|
|
|
+ SourcesUsed: strings.Join(sources, ","),
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ } else {
|
|
|
|
|
+ enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 2) 无知识库匹配:本回合允许联网时走「模型决定搜」function calling;否则仅用大模型知识
|
|
|
|
|
+ if needWeb && useWeb {
|
|
|
|
|
+ webSource := "custom"
|
|
|
|
|
+ if s.embeddingConfigSvc != nil {
|
|
|
|
|
+ webSource, _ = s.embeddingConfigSvc.GetWebSearchSource()
|
|
|
|
|
+ }
|
|
|
|
|
+ content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, userMessage, webSource, imageBase64, imageMimeType)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ log.Printf("⚠️ 联网(function calling)失败: %v,回退到仅大模型", err)
|
|
|
|
|
+ if s.systemLogSvc != nil {
|
|
|
|
|
+ _ = s.systemLogSvc.Create(CreateSystemLogInput{
|
|
|
|
|
+ Level: "warn",
|
|
|
|
|
+ Category: "ai",
|
|
|
|
|
+ Event: "web_fallback_to_llm",
|
|
|
|
|
+ Source: "backend",
|
|
|
|
|
+ ConversationID: &conversationID,
|
|
|
|
|
+ UserID: &userID,
|
|
|
|
|
+ Message: "联网失败,回退到仅大模型",
|
|
|
|
|
+ Meta: map[string]interface{}{
|
|
|
|
|
+ "error": err.Error(),
|
|
|
|
|
+ "web_source": webSource,
|
|
|
|
|
+ "ai_config": config.ID,
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) {
|
|
|
|
|
+ log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。")
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if content != "" {
|
|
|
|
|
+ sources = append(sources, "llm")
|
|
|
|
|
+ if usedWeb {
|
|
|
|
|
+ sources = append(sources, "web")
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.systemLogSvc != nil {
|
|
|
|
|
+ convID := conversationID
|
|
|
|
|
+ uID := userID
|
|
|
|
|
+ _ = s.systemLogSvc.Create(CreateSystemLogInput{
|
|
|
|
|
+ Level: "info",
|
|
|
|
|
+ Category: "ai",
|
|
|
|
|
+ Event: "ai_web_success",
|
|
|
|
|
+ Source: "backend",
|
|
|
|
|
+ ConversationID: &convID,
|
|
|
|
|
+ UserID: &uID,
|
|
|
|
|
+ Message: "联网生成成功",
|
|
|
|
|
+ Meta: map[string]interface{}{
|
|
|
|
|
+ "sources": strings.Join(sources, ","),
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return &GenerateAIResponseResult{
|
|
|
|
|
+ Content: content,
|
|
|
|
|
+ SourcesUsed: strings.Join(sources, ","),
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if useLLM && len(sources) == 0 {
|
|
|
|
|
+ enhancedMessage = s.buildNoKBPrompt(userMessage)
|
|
|
|
|
+ sources = append(sources, "llm")
|
|
|
|
|
+ } else if useLLM && len(sources) > 0 {
|
|
|
|
|
+ sources = append(sources, "llm")
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 8. 调用 AI 生成回复(使用增强的消息)
|
|
|
|
|
- response, err := provider.GenerateResponse(history, enhancedUserMessage)
|
|
|
|
|
|
|
+ // 无任何来源时(例如 useKB 且无匹配,useLLM 关):使用可配置回复语
|
|
|
|
|
+ if len(sources) == 0 {
|
|
|
|
|
+ reply := s.getNoSourceReply()
|
|
|
|
|
+ return &GenerateAIResponseResult{
|
|
|
|
|
+ Content: reply,
|
|
|
|
|
+ SourcesUsed: "",
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ response, err := provider.GenerateResponse(history, enhancedMessage, imageBase64, imageMimeType)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- // AI 调用失败,返回友好的错误消息
|
|
|
|
|
log.Printf("❌ AI 调用失败: %v", err)
|
|
log.Printf("❌ AI 调用失败: %v", err)
|
|
|
- return "AI客服好像出了点差错,请联系人工客服解决", nil
|
|
|
|
|
|
|
+ if s.systemLogSvc != nil {
|
|
|
|
|
+ _ = s.systemLogSvc.Create(CreateSystemLogInput{
|
|
|
|
|
+ Level: "error",
|
|
|
|
|
+ Category: "ai",
|
|
|
|
|
+ Event: "ai_generate_failed",
|
|
|
|
|
+ Source: "backend",
|
|
|
|
|
+ ConversationID: &conversationID,
|
|
|
|
|
+ UserID: &userID,
|
|
|
|
|
+ Message: "AI 调用失败,返回兜底回复",
|
|
|
|
|
+ Meta: map[string]interface{}{
|
|
|
|
|
+ "error": err.Error(),
|
|
|
|
|
+ "ai_config": config.ID,
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return &GenerateAIResponseResult{
|
|
|
|
|
+ Content: s.getAIFailReply(),
|
|
|
|
|
+ SourcesUsed: strings.Join(sources, ","),
|
|
|
|
|
+ GenerationFailed: true,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.systemLogSvc != nil {
|
|
|
|
|
+ convID := conversationID
|
|
|
|
|
+ uID := userID
|
|
|
|
|
+ event := "ai_llm_success"
|
|
|
|
|
+ if strings.Contains(strings.Join(sources, ","), "knowledge_base") {
|
|
|
|
|
+ event = "ai_rag_success"
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = s.systemLogSvc.Create(CreateSystemLogInput{
|
|
|
|
|
+ Level: "info",
|
|
|
|
|
+ Category: "ai",
|
|
|
|
|
+ Event: event,
|
|
|
|
|
+ Source: "backend",
|
|
|
|
|
+ ConversationID: &convID,
|
|
|
|
|
+ UserID: &uID,
|
|
|
|
|
+ Message: "AI 生成成功",
|
|
|
|
|
+ Meta: map[string]interface{}{
|
|
|
|
|
+ "sources": strings.Join(sources, ","),
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return &GenerateAIResponseResult{
|
|
|
|
|
+ Content: response,
|
|
|
|
|
+ SourcesUsed: strings.Join(sources, ","),
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GenerateImageReply 生图渠道专用:根据用户描述生成图片并保存到存储,返回说明文案与图片 URL。
|
|
|
|
|
+func (s *AIService) GenerateImageReply(conversationID uint, prompt string, userID uint) (*GenerateAIResponseResult, error) {
|
|
|
|
|
+ conversation, err := s.conversationRepo.GetByID(conversationID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("获取对话失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if conversation.AIConfigID == nil {
|
|
|
|
|
+ return nil, errors.New("生图渠道需要选择生图模型,请先在渠道中选择「生图绘画」并选择模型")
|
|
|
|
|
+ }
|
|
|
|
|
+ config, err := s.aiConfigRepo.GetByID(*conversation.AIConfigID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !config.IsActive {
|
|
|
|
|
+ return nil, errors.New("该生图模型已禁用")
|
|
|
|
|
+ }
|
|
|
|
|
+ if config.ModelType != "image" {
|
|
|
|
|
+ return nil, fmt.Errorf("当前选择的不是生图模型,model_type=%s", config.ModelType)
|
|
|
|
|
+ }
|
|
|
|
|
+ apiKey, err := utils.DecryptAPIKey(config.APIKey)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("解密 API Key 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var adapterConfig *AdapterConfig
|
|
|
|
|
+ if config.AdapterConfig != "" {
|
|
|
|
|
+ _ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig)
|
|
|
|
|
+ }
|
|
|
|
|
+ aiConfig := AIConfig{
|
|
|
|
|
+ APIURL: config.APIURL,
|
|
|
|
|
+ APIKey: apiKey,
|
|
|
|
|
+ Model: config.Model,
|
|
|
|
|
+ ModelType: config.ModelType,
|
|
|
|
|
+ Provider: config.Provider,
|
|
|
|
|
+ AdapterConfig: adapterConfig,
|
|
|
|
|
+ }
|
|
|
|
|
+ provider, err := s.providerFactory.CreateProvider(aiConfig)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ imgProvider, ok := provider.(ImageGenerationProvider)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ return nil, errors.New("当前提供商不支持生图")
|
|
|
|
|
+ }
|
|
|
|
|
+ imageData, mimeType, err := imgProvider.GenerateImage(prompt)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if s.storageService == nil {
|
|
|
|
|
+ return nil, errors.New("存储服务未配置,无法保存生成图片")
|
|
|
|
|
+ }
|
|
|
|
|
+ ext := ".png"
|
|
|
|
|
+ if strings.Contains(mimeType, "jpeg") || strings.Contains(mimeType, "jpg") {
|
|
|
|
|
+ ext = ".jpg"
|
|
|
|
|
+ }
|
|
|
|
|
+ fileURL, err := s.storageService.SaveMessageFile(conversationID, bytes.NewReader(imageData), "generated"+ext)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("保存生成图片失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ content := "已根据您的描述生成图片。"
|
|
|
|
|
+ return &GenerateAIResponseResult{
|
|
|
|
|
+ Content: content,
|
|
|
|
|
+ SourcesUsed: "",
|
|
|
|
|
+ GeneratedFileURL: &fileURL,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *AIService) buildNoKBPrompt(userMessage string) string {
|
|
|
|
|
+ if s.promptConfigSvc != nil {
|
|
|
|
|
+ tpl, err := s.promptConfigSvc.GetNoKBPromptTemplate()
|
|
|
|
|
+ if err == nil && tpl != "" {
|
|
|
|
|
+ return replaceUserMessageOnly(tpl, userMessage)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ return fmt.Sprintf(`你是一个智能客服助手。当前未使用知识库,请仅基于你的知识回答用户问题。
|
|
|
|
|
|
|
|
- return response, nil
|
|
|
|
|
|
|
+用户问题:%s
|
|
|
|
|
+
|
|
|
|
|
+请简洁、友好地回答。若无法回答,可建议用户联系人工客服。`, userMessage)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *AIService) buildWebSearchPrompt(userMessage string, webContext string) string {
|
|
|
|
|
+ if s.promptConfigSvc != nil {
|
|
|
|
|
+ tpl, err := s.promptConfigSvc.GetWebSearchResultPromptTemplate()
|
|
|
|
|
+ if err == nil && tpl != "" {
|
|
|
|
|
+ return replaceWebSearchPlaceholders(tpl, webContext, userMessage)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return fmt.Sprintf(`你是一个智能客服助手。请结合以下联网搜索结果回答用户问题。
|
|
|
|
|
+
|
|
|
|
|
+联网搜索结果:
|
|
|
|
|
+%s
|
|
|
|
|
+
|
|
|
|
|
+用户问题:%s
|
|
|
|
|
+
|
|
|
|
|
+请基于以上内容给出简洁、准确的回答。`, webContext, userMessage)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// replaceUserMessageOnly 仅替换 {{user_message}}
|
|
|
|
|
+func replaceUserMessageOnly(template, userMessage string) string {
|
|
|
|
|
+ return strings.ReplaceAll(template, "{{user_message}}", userMessage)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// replaceWebSearchPlaceholders 替换 {{web_context}}、{{user_message}}
|
|
|
|
|
+func replaceWebSearchPlaceholders(template, webContext, userMessage string) string {
|
|
|
|
|
+ template = strings.ReplaceAll(template, "{{web_context}}", webContext)
|
|
|
|
|
+ template = strings.ReplaceAll(template, "{{user_message}}", userMessage)
|
|
|
|
|
+ return template
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// getNoSourceReply 无任何来源时返回给用户的一句话(可配置)
|
|
|
|
|
+func (s *AIService) getNoSourceReply() string {
|
|
|
|
|
+ if s.promptConfigSvc != nil {
|
|
|
|
|
+ reply, err := s.promptConfigSvc.GetNoSourceReply()
|
|
|
|
|
+ if err == nil && strings.TrimSpace(reply) != "" {
|
|
|
|
|
+ return strings.TrimSpace(reply)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return "当前知识库暂无与此问题相关的内容,您可以尝试联系人工客服。"
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// getAIFailReply AI 调用失败时返回给用户的一句话(可配置)
|
|
|
|
|
+func (s *AIService) getAIFailReply() string {
|
|
|
|
|
+ if s.promptConfigSvc != nil {
|
|
|
|
|
+ reply, err := s.promptConfigSvc.GetAIFailReply()
|
|
|
|
|
+ if err == nil && strings.TrimSpace(reply) != "" {
|
|
|
|
|
+ return strings.TrimSpace(reply)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return "AI客服好像出了点差错,请联系人工客服解决"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// buildConversationHistory 构建对话历史(用于 AI 上下文)。
|
|
// buildConversationHistory 构建对话历史(用于 AI 上下文)。
|
|
@@ -212,11 +605,20 @@ func (s *AIService) retrieveRAGContext(ctx context.Context, query string, conver
|
|
|
// buildRAGPrompt 构建包含 RAG 上下文的 Prompt
|
|
// buildRAGPrompt 构建包含 RAG 上下文的 Prompt
|
|
|
// userMessage: 用户原始消息
|
|
// userMessage: 用户原始消息
|
|
|
// ragContext: RAG 检索到的文档内容
|
|
// ragContext: RAG 检索到的文档内容
|
|
|
-// 返回: 增强后的用户消息(包含知识库上下文)
|
|
|
|
|
|
|
+// 返回: 增强后的用户消息(包含知识库上下文)。若已配置提示词服务则使用可配置模板(占位符 {{rag_context}}、{{user_message}}),否则使用代码内默认。
|
|
|
func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string {
|
|
func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string {
|
|
|
- // 构建 RAG Prompt 模板
|
|
|
|
|
- // 参考 PandaWiki 的 Prompt 格式
|
|
|
|
|
- prompt := fmt.Sprintf(`你是一个智能客服助手,请基于以下知识库内容回答用户的问题。
|
|
|
|
|
|
|
+ if s.promptConfigSvc != nil {
|
|
|
|
|
+ tpl, err := s.promptConfigSvc.GetRAGPromptTemplate()
|
|
|
|
|
+ if err == nil && tpl != "" {
|
|
|
|
|
+ return replacePromptPlaceholders(tpl, ragContext, userMessage)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return s.buildRAGPromptFallback(userMessage, ragContext)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// buildRAGPromptFallback 代码内默认 RAG 提示词(与 prompt_config_service 默认一致,用于 promptConfigSvc 为空或出错时)
|
|
|
|
|
+func (s *AIService) buildRAGPromptFallback(userMessage string, ragContext string) string {
|
|
|
|
|
+ return fmt.Sprintf(`你是一个智能客服助手,请基于以下知识库内容回答用户的问题。
|
|
|
|
|
|
|
|
知识库内容:
|
|
知识库内容:
|
|
|
%s
|
|
%s
|
|
@@ -231,6 +633,157 @@ func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string
|
|
|
3. 如果知识库中没有相关信息,请诚实告知
|
|
3. 如果知识库中没有相关信息,请诚实告知
|
|
|
4. 保持友好、专业的语气
|
|
4. 保持友好、专业的语气
|
|
|
5. 回答要简洁明了,避免冗长`, ragContext, userMessage)
|
|
5. 回答要简洁明了,避免冗长`, ragContext, userMessage)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// replacePromptPlaceholders 将模板中的 {{rag_context}}、{{user_message}} 替换为实际值
|
|
|
|
|
+func replacePromptPlaceholders(template, ragContext, userMessage string) string {
|
|
|
|
|
+ template = strings.ReplaceAll(template, "{{rag_context}}", ragContext)
|
|
|
|
|
+ template = strings.ReplaceAll(template, "{{user_message}}", userMessage)
|
|
|
|
|
+ return template
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// buildRAGPromptWithWebOptional 构建 RAG prompt,并允许在知识库无关或不足时用自身知识或联网。
|
|
|
|
|
+// 与 buildRAGPrompt 区别:明确说明可先基于知识库,若无关/弱相关可基于自身知识,若仍不足可由模型决定是否联网(需配合传入 web_search 工具使用)。
|
|
|
|
|
+func (s *AIService) buildRAGPromptWithWebOptional(userMessage string, ragContext string) string {
|
|
|
|
|
+ if s.promptConfigSvc != nil {
|
|
|
|
|
+ tpl, err := s.promptConfigSvc.GetRAGPromptWithWebOptionalTemplate()
|
|
|
|
|
+ if err == nil && tpl != "" {
|
|
|
|
|
+ return replacePromptPlaceholders(tpl, ragContext, userMessage)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return s.buildRAGPromptWithWebOptionalFallback(userMessage, ragContext)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// buildRAGPromptWithWebOptionalFallback 代码内默认(RAG+联网可选)
|
|
|
|
|
+func (s *AIService) buildRAGPromptWithWebOptionalFallback(userMessage string, ragContext string) string {
|
|
|
|
|
+ return fmt.Sprintf(`你是一个智能客服助手。请优先基于以下知识库内容回答用户的问题。
|
|
|
|
|
+
|
|
|
|
|
+知识库内容:
|
|
|
|
|
+%s
|
|
|
|
|
+
|
|
|
|
|
+用户问题:%s
|
|
|
|
|
+
|
|
|
|
|
+回答要求:
|
|
|
|
|
+1. 若知识库内容与问题明确相关,请基于知识库给出准确、简洁的回答。
|
|
|
|
|
+2. 若知识库内容与问题无关或仅弱相关,可先基于你自身的知识回答,不必拘泥于知识库。
|
|
|
|
|
+3. 若你自身知识仍不足以回答(例如需要最新资讯、实时数据),你可决定是否使用联网搜索获取信息后再回答。
|
|
|
|
|
+4. 保持友好、专业,回答简洁明了。`, ragContext, userMessage)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const maxWebToolRounds = 5
|
|
|
|
|
+
|
|
|
|
|
+// webSearchToolDefinition 返回 type: "function" 的 web_search 工具定义,仅用于「自建」联网(Serper 执行)。
|
|
|
|
|
+func (s *AIService) webSearchToolDefinition() []map[string]interface{} {
|
|
|
|
|
+ return []map[string]interface{}{
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": map[string]interface{}{
|
|
|
|
|
+ "name": "web_search",
|
|
|
|
|
+ "description": "Search the web for current information. Use when you need up-to-date or external information to answer the user.",
|
|
|
|
|
+ "parameters": map[string]interface{}{
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": map[string]interface{}{
|
|
|
|
|
+ "query": map[string]string{"type": "string", "description": "Search query"},
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": []string{"query"},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- return prompt
|
|
|
|
|
|
|
+// generateWithWebTools 使用 function calling 做联网(模型决定是否搜)。webSource: vendor / custom。
|
|
|
|
|
+// 联网请求始终发往当前对话的「AI 配置」对话接口(与知识库向量配置/embedding 无关)。
|
|
|
|
|
+// - vendor(模式一:厂商内置):在 tools 里传 type "web_search",由厂商在自家 API 内封装并执行搜索,无需自建。
|
|
|
|
|
+// - custom(模式二:自建):在 tools 里传 type "function" 的自定义函数(如 web_search),由本服务调用 Serper 等执行并回填。
|
|
|
|
|
+func (s *AIService) generateWithWebTools(ctx context.Context, provider AIProvider, history []MessageHistory, userMessage string, webSource string, imageBase64 string, imageMimeType string) (content string, usedWeb bool, err error) {
|
|
|
|
|
+ messages := s.historyToOpenAIMessages(history, userMessage, imageBase64, imageMimeType)
|
|
|
|
|
+ var tools []map[string]interface{}
|
|
|
|
|
+ useFunctionFormat := false
|
|
|
|
|
+ switch webSource {
|
|
|
|
|
+ case "vendor":
|
|
|
|
|
+ // 模式一:厂商内置,仅传 web_search,由厂商执行
|
|
|
|
|
+ tools = []map[string]interface{}{
|
|
|
|
|
+ {"type": "web_search"},
|
|
|
|
|
+ }
|
|
|
|
|
+ case "custom":
|
|
|
|
|
+ if s.webSearchProvider == nil {
|
|
|
|
|
+ return "", false, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ useFunctionFormat = true
|
|
|
|
|
+ tools = s.webSearchToolDefinition()
|
|
|
|
|
+ default:
|
|
|
|
|
+ tools = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(tools) == 0 {
|
|
|
|
|
+ return "", false, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rounds := 0
|
|
|
|
|
+ for rounds < maxWebToolRounds {
|
|
|
|
|
+ rounds++
|
|
|
|
|
+ respContent, toolCalls, callErr := provider.GenerateResponseWithTools(messages, tools)
|
|
|
|
|
+ if callErr != nil {
|
|
|
|
|
+ return "", usedWeb, callErr
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(toolCalls) == 0 {
|
|
|
|
|
+ return respContent, usedWeb, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if useFunctionFormat {
|
|
|
|
|
+ usedWeb = true
|
|
|
|
|
+ }
|
|
|
|
|
+ // 追加 assistant 消息(含 tool_calls)
|
|
|
|
|
+ assistantMsg := map[string]interface{}{"role": "assistant", "content": respContent}
|
|
|
|
|
+ tcList := make([]map[string]interface{}, 0, len(toolCalls))
|
|
|
|
|
+ for _, tc := range toolCalls {
|
|
|
|
|
+ tcList = append(tcList, map[string]interface{}{
|
|
|
|
|
+ "id": tc.ID,
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": map[string]interface{}{"name": tc.Name, "arguments": tc.Arguments},
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ assistantMsg["tool_calls"] = tcList
|
|
|
|
|
+ messages = append(messages, assistantMsg)
|
|
|
|
|
+
|
|
|
|
|
+ for _, tc := range toolCalls {
|
|
|
|
|
+ toolResult := ""
|
|
|
|
|
+ if useFunctionFormat && tc.Name == "web_search" && s.webSearchProvider != nil {
|
|
|
|
|
+ var args struct {
|
|
|
|
|
+ Query string `json:"query"`
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = json.Unmarshal([]byte(tc.Arguments), &args)
|
|
|
|
|
+ query := args.Query
|
|
|
|
|
+ if query == "" {
|
|
|
|
|
+ query = userMessage
|
|
|
|
|
+ }
|
|
|
|
|
+ toolResult, _ = s.webSearchProvider.Search(ctx, query)
|
|
|
|
|
+ }
|
|
|
|
|
+ messages = append(messages, map[string]interface{}{
|
|
|
|
|
+ "role": "tool",
|
|
|
|
|
+ "tool_call_id": tc.ID,
|
|
|
|
|
+ "content": toolResult,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return "", usedWeb, fmt.Errorf("联网工具调用超过 %d 轮", maxWebToolRounds)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *AIService) historyToOpenAIMessages(history []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) []map[string]interface{} {
|
|
|
|
|
+ out := make([]map[string]interface{}, 0, len(history)+1)
|
|
|
|
|
+ for _, h := range history {
|
|
|
|
|
+ out = append(out, map[string]interface{}{"role": h.Role, "content": h.Content})
|
|
|
|
|
+ }
|
|
|
|
|
+ var lastContent interface{} = userMessage
|
|
|
|
|
+ if imageBase64 != "" {
|
|
|
|
|
+ dataURL := "data:" + imageMimeType + ";base64," + imageBase64
|
|
|
|
|
+ if imageMimeType == "" {
|
|
|
|
|
+ dataURL = "data:image/jpeg;base64," + imageBase64
|
|
|
|
|
+ }
|
|
|
|
|
+ lastContent = []map[string]interface{}{
|
|
|
|
|
+ {"type": "text", "text": userMessage},
|
|
|
|
|
+ {"type": "image_url", "image_url": map[string]string{"url": dataURL}},
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, map[string]interface{}{"role": "user", "content": lastContent})
|
|
|
|
|
+ return out
|
|
|
}
|
|
}
|